How I built a Zero-Knowledge Secret Sharer using Next.js and the Web Crypto API
Source: Dev.to

Most “secure” sharing tools require you to trust the server. You paste your password, the server encrypts it, and stores it. But if the server logs the request, or if the database leaks, your secret is gone.
I wanted a tool where I (the developer) literally could not read the data even if I wanted to.
So I built Nix (https://nix.jaid.dev), an open‑source, zero‑knowledge secret sharing app. Below is a technical breakdown of how it works, using AES‑GCM and the URL hash fragment.
The Architecture
The core constraint was Zero Knowledge – the server must never receive the decryption key.
- Alice generates a random key in her browser.
- Alice encrypts the data client‑side.
- Alice sends only the ciphertext (wrapped in a JSON envelope) to the server (Supabase).
- The browser constructs a link:
https://nix.jaid.dev/view/[ID]#[KEY]. - Bob clicks the link. His browser requests the ID, extracts the
#[KEY]from the URL (which was never sent to the server), and decrypts the data locally.
The Stack
- Frontend: Next.js 16 (App Router)
- Database: Supabase (Postgres)
- Crypto: Native Web Crypto API (
window.crypto.subtle) - Styling: Tailwind CSS
The Hard Part: Web Crypto API
The Web Crypto API is powerful but verbose. Below are the key pieces of the encryption flow.
Generating the Key
We need a cryptographically strong random key. The SubtleCrypto API guarantees secure generation.
// Generate a secure AES‑GCM key
const key = await window.crypto.subtle.generateKey(
{ name: "AES-GCM", length: 256 },
true,
["encrypt", "decrypt"]
);
// Export to raw bytes (and then to Base64) for the URL
const exported = await window.crypto.subtle.exportKey("raw", key);
The Encryption (AES‑GCM)
AES‑GCM provides both confidentiality and integrity. A unique IV must be generated for every encryption operation.
async function encrypt(content, key) {
const encoder = new TextEncoder();
const encodedContent = encoder.encode(content);
// 96‑bit IV for GCM
const iv = window.crypto.getRandomValues(new Uint8Array(12));
const encryptedContent = await window.crypto.subtle.encrypt(
{
name: "AES-GCM",
iv: iv,
},
key,
encodedContent
);
// Return serialized JSON with IV and ciphertext
return JSON.stringify({
iv: Array.from(iv),
data: Array.from(new Uint8Array(encryptedContent)),
});
}
The URL Hash Hack
When a browser visits example.com/page#secret123, the server only sees GET /page. Everything after # never reaches the server, allowing us to transport the decryption key safely.
// On the client (e.g., inside useEffect)
useEffect(() => {
const hash = window.location.hash; // "#5f3a..."
if (hash) {
const keyString = hash.substring(1); // Remove '#'
// Trigger decryption...
}
}, []);
Database & Expiration (Supabase)
Since the server stores only encrypted blobs, Supabase with Row‑Level Security (RLS) handles storage. The client enforces “burn on read” and expiration:
- Fetch: Retrieve the encrypted record.
- Check Expiration: Compare
expires_atwith the current time; if passed, treat the secret as expired and delete it. - Burn on Read: If the metadata marks the secret as “Burn on Read”, issue a delete request to Supabase immediately after successful retrieval.
Lessons Learned
- Hydration Errors: Next.js Server Components lack
window. Invokewindow.cryptoonly insideuseEffector event handlers. - Encoding Hell: Converting between
ArrayBuffer,Uint8Array, and strings is cumbersome.TextEncoderandTextDecoderare essential. - Trust: Transparency is vital for security tools. The repository is open source from day one because I wouldn’t use a closed‑source solution myself.
Try It Out
I’m looking for feedback on the encryption implementation and the UX.
- Live Demo:
- Repo: (Stars appreciated!)