Create flow — sender's browser and server
Reveal flow — recipient's browser and 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
textContentonly, neverinnerHTML, 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