The lifecycle, at a glance
Step by step
Someone writes a secret message in their browser.
Nothing is transmitted anywhere yet.
Message composed client-side onlyBehind the scenes, the browser quietly creates five random "codes."
Ouh — this part: a record number, two secret codes, and two supporting values, all generated instantly on your device.
Message ID, URL Secret, Challenge Secret, KDF salt, IV — 96 to 256 bits eachBefore it leaves the device, the browser locks the message.
Like sealing a letter in a tamper-proof envelope only the right key can open.
AES-256-GCM, performed in-browser via the Web Crypto APIThe lock actually needs two separate keys, not one.
Like a safe deposit box that needs both a bank key and a customer key together — and those two secrets get turned into two different working keys, one for locking and a completely separate one just for proving identity later.
Two keys derived via HKDF-SHA-256, domain-separated so neither can do the other's jobOnly the sealed envelope is sent to the server.
Never the original message, never either key.
Only ciphertext, an IV, and a one-time proof are POSTedThe server stores the sealed envelope, nothing more.
Even someone with full database access would only find scrambled, unreadable data.
Ciphertext-only storage via parameterized queriesThe sender gets a link and a separate Challenge Key.
Meant to travel through two different channels — the link by email, the key by text, for example.
URL Secret lives in the link's fragment, never sent to any serverThe recipient's browser combines both pieces to unlock it.
Without ever sending anything unlockable anywhere.
Keys re-derived locally via HKDF; no server round-trip needed firstThe server checks a one-time proof, then deletes its copy instantly.
In the same instant it confirms the proof is correct.
Atomic transaction: lock, verify in constant time, delete, then respondThe recipient's browser unlocks and displays the message, once.
Refreshing the page, or trying the link again, brings back nothing.
Client-side decryption; the database row no longer existsCreate flow — sender's browser and server
The five values generated in step 1 ("Generate secrets") — click any to jump to its full breakdown:
- Message ID — 128 bits
- URL Secret — 256 bits
- Challenge Secret — 160 bits
- KDF Salt — 256 bits
- AES-GCM IV — 96 bits
Reveal flow — recipient's browser and server
Worked example — one message, start to finish
Every value below is a real, correctly-sized example — not shortened for display — for one imagined message: "Meeting moved to 3pm" (21 characters).
MESSAGE ID (see breakdown)
k3Jt9QvXn7Zp2LmWfC8dRg
URL SECRET (see breakdown)
vN8pQ2xR7mYtL5wZaK3jHfD9sB1cVn0iOe4gU6qWyTz
KDF SALT (see breakdown)
H6uJ2kLpQ9xZmN3vC7bY5wR8tS1fD4gA0eK6jM3nPQx
CHALLENGE KEY — shown to the sender, typed by the recipient (see breakdown)
K3JT-9QVX-N7ZP-2LMW-FC8D-RG5T-YBC1-ZL9M
THE FULL LINK THE SENDER GETS
https://secretlah.safarazsarazli.com/?id=k3Jt9QvXn7Zp2LmWfC8dRg#k=vN8pQ2xR7mYtL5wZaK3jHfD9sB1cVn0iOe4gU6qWyTz&s=H6uJ2kLpQ9xZmN3vC7bY5wR8tS1fD4gA0eK6jM3nPQx
Everything after the # — the URL Secret and the salt — never reaches any server,
by design. Only ?id=k3Jt9QvXn7Zp2LmWfC8dRg is ever transmitted when this link is opened.
EXACT REQUEST BODY SENT TO create.php
{
"id": "k3Jt9QvXn7Zp2LmWfC8dRg",
"ciphertext": "qP2xR9mYtL5wZaK3jHfD8sB1cVn0iOe4gU6qWyTk7NpQ2xRz1M",
"iv": "9mQpL2vNc8YaB3xK",
"authVerifier": "T6qWyZ8fN2pQwXeR5tYbC1zL9mKjHgD4sA7uVn0iOpQ"
}
This is the entire request. No plaintext, no URL Secret, no Challenge Key, no salt — the server stores exactly these four fields as one record, and nothing else.
EXACT REQUEST BODY SENT TO consume.php, WHEN THE RECIPIENT CLICKS REVEAL
{
"id": "k3Jt9QvXn7Zp2LmWfC8dRg",
"authVerifier": "T6qWyZ8fN2pQwXeR5tYbC1zL9mKjHgD4sA7uVn0iOpQ"
}
The recipient's browser computed this authVerifier independently, using the URL
Secret and salt from the link's fragment plus the Challenge Key they just typed — it happens to
match the one the sender's browser computed, only if the Challenge Key was correct.
WHAT THE SERVER ACTUALLY CHECKS
The server looks up the record matching this message ID, then compares the
authVerifier from the request against the proof it stored at creation time,
using a constant-time comparison so the check itself can't leak information through timing.
If they match, the record is deleted in that same instant and the stored ciphertext and IV
are sent back — the exact same values shown above. The browser then decrypts that with the
Encryption Key it already derived, and "Meeting moved to 3pm" appears on screen. If the
comparison fails for any reason, the server never tells the difference between "wrong Challenge
Key," "already used," or "never existed" — every failure looks identical from outside.
Every generated value, in detail
Every random or derived value in this protocol — what it is in plain terms, an example of what it actually looks like, and why this specific choice was made rather than an arbitrary one. A note on terminology: "IV" and "nonce" are used interchangeably across different cryptography libraries; for AES-GCM specifically the precise term is IV, and it must behave as a nonce (never reused with the same key) — both terms here mean the same 96-bit value.
A random filename for the record — identifies which secret this is, reveals nothing about its contents.
EXAMPLE
k3Jt9QvXn7Zp2LmWfC8dRg
WHY 128 BITS
With roughly 3.4 × 10^38 possible values, guessing or scanning for a live ID is computationally pointless — generating millions of them never creates a meaningful collision risk either.
Half of the "key" needed to unlock the message — the half that travels inside the link itself.
EXAMPLE
vN8pQ2xR7mYtL5wZaK3jHfD9sB1cVn0iOe4gU6qWyTz
WHY 256 BITS
256 bits is the accepted floor for strong symmetric-key-equivalent security today. This is the more exposed of the two secrets in practice — URLs get logged, cached, and pasted around far more casually than a code someone reads aloud — so it gets the larger margin.
The other half of the key — the part sent through a separate channel and typed in by hand.
EXAMPLE
K3JT-9QVX-N7ZP-2LMW-FC8D-RG5T-YBC1-ZL9M
WHY 160 BITS, AND WHY THIS ENCODING
160 bits comfortably exceeds a 128-bit security floor while landing on a byte count (20) that divides evenly into both 8-bit bytes and 5-bit Base32 characters — exactly 32 characters, no wasted or truncated bits. Crockford Base32's alphabet deliberately excludes I, L, O, and U, because this key is meant to be read aloud on a call or typed from memory, and those are the characters people misread or mishear most often.
A non-secret "seasoning" value mixed in so the same two secrets could never accidentally produce the same keys twice.
EXAMPLE
H6uJ2kLpQ9xZmN3vC7bY5wR8tS1fD4gA0eK6jM3nPQx
WHY 256 BITS, AND WHY IT DOESN'T NEED TO BE SECRET
A salt's job isn't secrecy — it's uniqueness. 256 bits matches the output size of SHA-256, which is what HKDF is built on here, and is the size HKDF's own specification recommends for the salt.
A one-time-use random value the encryption algorithm needs in order to scramble the message differently every single time, even with the same key.
EXAMPLE
9mQpL2vNc8YaB3xK
WHY 96 BITS SPECIFICALLY
This isn't an arbitrary size — it's the exact size AES-GCM's authors designed and optimized the algorithm around. The one rule that matters more than the size itself: it must never repeat under the same key, which is why a fresh one is generated for every message, with no exceptions.
The actual key that locks and unlocks the message — it's built fresh in memory each time, never written down anywhere.
HOW IT'S BUILT
HKDF-SHA-256(IKM = URL Secret + Challenge Secret, salt = KDF Salt, info = "SecretLah/v1/encryption")
WHY AES-256, AND WHY HKDF
AES-256 is chosen over AES-128 because brute-forcing a 256-bit key stays firmly outside anything physically achievable with current or realistically foreseeable computing power, quantum included. HKDF-SHA-256 is used to combine the two secrets because it's a well-vetted, standardized way to turn shared input material into a strong key — never using either raw secret directly as a key.
A second key, built the same way as the Encryption Key but kept strictly for a different job — proving identity, never scrambling data.
HOW IT'S BUILT
HKDF-SHA-256(IKM = URL Secret + Challenge Secret, salt = KDF Salt, info = "SecretLah/v1/authentication")
WHY A SEPARATE KEY (DOMAIN SEPARATION)
Notice this uses the exact same secrets and salt as the Encryption Key above — only the "info" label differs. That single difference is enough to make the two keys cryptographically unrelated. This technique, called domain separation, costs nothing computationally and guarantees a weakness in how one key is used could never cascade into the other.
Proof that a browser knows the correct secrets, without ever revealing what those secrets actually are.
EXAMPLE
T6qWyZ8fN2pQwXeR5tYbC1zL9mKjHgD4sA7uVn0iOpQ
WHY HMAC-SHA-256
HMAC is a one-way construction — the server can check that this value matches what it stored without ever being able to work backwards from it to recover the Authentication Key or either secret. SHA-256 keeps it consistent with the rest of the protocol and remains fast and unbroken.
Not a secret or a random value — a label glued to the encryption itself, so the scrambled message can't be swapped with a different one.
EXAMPLE
SecretLah/v1:k3Jt9QvXn7Zp2LmWfC8dRg
WHY BIND THE CIPHERTEXT TO THE ID
If two ciphertexts were ever mixed up between database rows, this binding makes decryption fail loudly and immediately, rather than silently producing the wrong — but still valid-looking — message for the wrong recipient.
The actual locked message — what the server stores, and all it ever sees.
EXAMPLE (encrypting "Meeting moved to 3pm" — 21 characters)
qP2xR9mYtL5wZaK3jHfD8sB1cVn0iOe4gU6qWyTk7NpQ2xRz1M
WHY THE TAG MATTERS
GCM's built-in authentication tag is what turns plain encryption into encryption you can trust wasn't tampered with. Without it, an attacker could flip bits in the ciphertext and get correspondingly altered — but undetected — plaintext back. With it, any tampering at all makes decryption fail outright rather than returning corrupted data silently.
What the server is never given, at any point
- The plaintext message
- The URL Secret
- The Challenge Key
- The KDF salt
- Either derived key
Rate limiting and additional hardening
- A global error handler on every API request guarantees no internal detail — a file path, a database error, a stack trace — can ever leak into a response; failures are logged server-side only and returned to the client as a generic message
- Both API endpoints validate the Origin header and, where a browser sends it, the Sec-Fetch-Site header, rejecting explicit cross-site requests
- Creating a secret is capped at 20 requests per 5 minutes per IP address
- Revealing a secret is capped at 30 requests per 5 minutes per IP address overall, plus a much tighter 5 requests per 5 minutes against any single specific link
- Rate limiting stores no raw IP address at any point — only a SHA-256 hash of (IP + endpoint + a short time window), deleted by the application itself once that window passes
- HTTPS is enforced site-wide, with a full security header set on every response: Content-Security-Policy, Strict-Transport-Security, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy, and both Cross-Origin isolation headers
- 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