DEV Community

Cover image for How I built a Zero-Knowledge Secret Sharer using Next.js and the Web Crypto API
Derick J. David
Derick J. David

Posted on

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

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. Here is the 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.

To achieve this, we use the browser's URL fragment (#).

  1. Alice generates a random key in her browser.
  2. Alice encrypts the data client-side.
  3. Alice sends only the ciphertext (wrapped in a JSON envelope) to the server (Supabase).
  4. The browser constructs a link: https://nix.jaid.dev/view/[ID]#[KEY]
  5. Bob clicks the link. His browser requests the ID.
  6. His browser 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. Here is how I handled the encryption flow.

Generating the Key

We need a cryptographic-strength random key. I used the SubtleCrypto API to ensure it's generated securely.

// 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);

Enter fullscreen mode Exit fullscreen mode

The Encryption (AES-GCM)

I chose AES-GCM because it provides both confidentiality and integrity.

One "gotcha" with AES-GCM is the Initialization Vector (IV). You must generate a unique IV for every single 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
  // We convert the typed arrays to regular arrays for easy stringification
  return JSON.stringify({
    iv: Array.from(iv),
    data: Array.from(new Uint8Array(encryptedContent))
  });
}

Enter fullscreen mode Exit fullscreen mode

The URL Hash Hack

This is the "magic" trick.

When a browser visits example.com/page#secret123, the server only sees GET /page. It ignores everything after the hash.

This allows us to transport the decryption key from Alice to Bob via the link, without the server ever intercepting it.

// On the Client (useEffect)
useEffect(() => {
  const hash = window.location.hash; // "#5f3a..."
  if (hash) {
     const keyString = hash.substring(1); // Remove '#'
     // Trigger decryption...
  }
}, []);

Enter fullscreen mode Exit fullscreen mode

Database & Expiration (Supabase)

Since the server holds only encrypted blobs, I used Supabase with Row Level Security (RLS) to handle storage.

To handle "Burn on Read" and expiration, the application logic enforces the rules before showing the secret:

  1. Fetch: The client retrieves the encrypted record.
  2. Check Expiration: The client compares the expires_at timestamp with the current time. If it's passed, the secret is considered expired and deleted.
  3. Burn on Read: If the metadata marks the secret as "Burn on Read", the client immediately issues a delete request to Supabase as soon as the data is successfully retrieved.

Lessons Learned

  1. Hydration Errors: Next.js Server Components don't have access to window. You have to be very careful to only invoke window.crypto in useEffect or event handlers.
  2. Encoding Hell: Moving between ArrayBuffer, Uint8Array, and strings is painful. The TextEncoder and TextDecoder APIs are your friends.
  3. Trust: Building a security tool requires transparency. I made the repo open source immediately because I wouldn't use a closed-source tool for this myself.

Try it out

I’m looking for feedback on the encryption implementation and the UX.

Top comments (0)