Create flow — sender's browser and server

Sender's browser Server Generate secrets ID, URL Secret, Challenge Secret, salt, IV Derive two keys HKDF-SHA-256, two independent keys Encrypt the message AES-256-GCM, bound to the message ID Compute auth proof HMAC-SHA-256 keyed by Authentication Key Send ciphertext only POST ciphertext + IV + proof Show link + Challenge Key Displayed separately, for separate channels Store the row Parameterized insert — no secrets received Confirm saved Returns success only
Request to server Response from server

Reveal flow — recipient's browser and server

Recipient's browser Server Open the link URL Secret + salt already in the fragment Enter Challenge Key Decoded from Crockford Base32 Derive the same two keys HKDF-SHA-256 — no request needed yet Compute auth proof HMAC-SHA-256 of the message ID Send one request POST message ID + proof, once Decrypt and display AES-256-GCM, shown once via textContent Lock the row SELECT ... FOR UPDATE, inside a transaction Verify, then delete Constant-time check, DELETE in same transaction Return ciphertext Only on a valid proof; otherwise generic failure
Request to server Response from server

The flow, end to end

Sender composes the message

Typed directly into the browser. Nothing is sent anywhere yet.

Browser generates four secrets, locally

A message ID, a URL Secret, a Challenge Secret, and a KDF salt — all from crypto.getRandomValues(), none of them typed by a human.

Two keys are derived, never one

HKDF-SHA-256 combines the URL Secret and Challenge Secret to derive an independent Encryption Key and Authentication Key — domain-separated, so neither can be reused for the other's job.

The message is encrypted, locally

AES-256-GCM, bound to the message ID as authenticated data, so ciphertext can't be silently swapped between records.

Only ciphertext leaves the browser

Ciphertext, an IV, and a one-time proof are sent to the server. The plaintext, both secrets, the salt, and both keys never are.

Sender distributes two channels, separately

The link (message ID + URL Secret + salt) goes one way; the Challenge Key goes another. Neither is useful without the other.

Recipient opens the link, nothing happens yet

The page waits for an explicit action — opening the link alone (including automated link-preview scanners) never triggers a reveal.

Recipient enters the Challenge Key

The browser already has everything else it needs from the link itself, so this is the only missing piece.

One request, atomically verified and consumed

The server locks the row, checks the proof in constant time, and deletes the record in the same transaction that serves it — before the ciphertext is ever returned.

Browser decrypts and displays, once

Rendered as plain text only. Refreshing the page cannot bring it back — the row is already gone.

Message creation, in full

  • Generate a 128-bit random message ID
  • Generate a 256-bit random URL Secret
  • Generate a 160-bit random Challenge Secret, encoded as 32 Crockford Base32 characters in 8 groups of 4
  • Generate a 256-bit random KDF salt
  • Generate a 96-bit random AES-GCM IV
  • Derive an Encryption Key and an Authentication Key via HKDF-SHA-256, using separate domain-separation labels for each
  • Encrypt the message with AES-256-GCM, using the message ID and protocol version as additional authenticated data
  • Compute a one-time authentication proof: HMAC-SHA-256 of the message ID, keyed with the Authentication Key
  • Send only the ciphertext, IV, and proof to the server over a same-origin, authenticated request
  • Server stores the row with a parameterized query; the salt is never sent or stored server-side at all
  • Browser assembles the link with the message ID in the query string and the URL Secret plus salt in the fragment, which browsers never transmit to any server

Reveal, in full

  • Recipient's browser detects reveal mode by checking for both a query ID and a fragment secret
  • Challenge Key input is normalized: case-insensitive, tolerant of common O/0 and I/L/1 misreads, then decoded from Crockford Base32
  • Browser re-derives the same Encryption Key and Authentication Key locally — no request to the server is needed first
  • Browser computes the same HMAC-SHA-256 proof and sends exactly one request: the message ID and the proof
  • Server opens a database transaction and locks the row before reading it
  • The proof is checked using a constant-time comparison
  • On a match, the row is deleted in the same transaction, before the ciphertext is returned
  • A wrong Challenge Key, an already-consumed link, and a link that never existed all return the exact same response — there is no way to tell these apart from outside
  • Browser decrypts the returned ciphertext with AES-256-GCM, verifying the same authenticated data
  • The result is written into the page with textContent only, never innerHTML, so a malicious payload in the secret itself cannot execute

What the server is never given, at any point

  • The plaintext message
  • The URL Secret
  • The Challenge Key
  • The KDF salt
  • Either derived key

Transport and hosting protections

  • Origin validation on both API endpoints — requests are accepted only from this same site
  • HTTPS is required; no plaintext fallback
  • No third-party scripts, fonts, or CDNs of any kind — everything is served from this origin
  • Frontend assets are cache-busted by file modification time, so a re-deployed file can never be served stale by a hosting-level or browser cache