How it works
I built this to send someone an API key or a login without it sitting in a chat history forever. Here's what it protects, what it can't, and where to check my claims.
What's protected
- Your browser encrypts the Secret before anything leaves the page. My server only ever gets ciphertext.
- The Link key sits after the
#in the Link. Browsers never send that part to a server, so mine never sees it. - I store the ciphertext, the IV, the salt if there's a Passphrase, a has-passphrase flag and the Expiry. Nothing else. Someone who broke into my storage would get ciphertext they can't read.
- A Link works once. Reveal takes the Secret and Burns it in one step, so even two Reveals at the same moment can't both get it.
What isn't
- A compromised device or browser. Malware or a bad browser extension on either end can read the Secret before it's encrypted or after it's decrypted.
- A leaked Link without a Passphrase. The Link alone unlocks the Secret, so whoever opens it first gets it. Your recipient would then see the "gone" message, which tells them to warn you. For anything important, add a Passphrase and send it another way.
- A recipient who copies the Secret elsewhere. Once it's on their screen, I can't stop them pasting it into a chat.
- The code I serve. The encryption runs in JavaScript my server sends you. If my server or my Cloudflare account were taken over, it could serve code that leaks the Link key. The source is public, but you're trusting that what runs is what's published.
The encryption
- AES-256-GCM through the browser's built-in WebCrypto. No crypto library, and the server never decrypts anything.
- Every Secret gets a fresh 256-bit Link key and a fresh 96-bit IV from the browser's random number generator. Without a Passphrase, the Link key is the AES key.
- With a Passphrase, PBKDF2-SHA256 stretches it with 600,000 iterations and a fresh 128-bit salt, then HKDF-SHA256 mixes the result with the Link key. You need both the Link and the Passphrase, and a wrong Passphrase fails GCM's integrity check.
- A Link looks like
/s/<id>#<link-key>, with.pon the end when there's a Passphrase, so the Reveal page can tell you before you click. - Secret IDs are 128 random bits made on the server, so nobody can guess or list them.
Reveal, then Burn
Opening a Link doesn't touch the Secret. Slack, iMessage and Teams fetch a link as soon as it's pasted, to build a preview. If opening it Burned the Secret, a bot would use it up before your recipient got there. So the page waits for a click on Reveal, then fetches and Burns the Secret in one step.
A Passphrase can't be checked before the Burn, because my server can't check it at all. So the ciphertext stays in the tab, and you can retry a wrong Passphrase as often as you like. Close the tab and it's gone. That gives an attacker nothing: whoever holds the Link could take the ciphertext and guess offline anyway. The 600,000 PBKDF2 iterations are what make each guess slow, so pick a Passphrase that isn't a single common word.
Expiry
You pick 1 hour, 1 day (the default) or 7 days. An unrevealed Secret Burns at its Expiry: an alarm on its storage removes it, and a Reveal after the Expiry finds it gone even if the alarm hasn't run yet.
Whatever happened, whether the Secret never existed, was already revealed or expired, the recipient sees the same message. The page doesn't say which.
No third-party code
Anything running on the page can read the Link key from the address bar, including a "harmless" analytics script. So the page loads only its own code: no analytics, no trackers, no CDN, and the fonts are hosted here.
A strict Content Security Policy (default-src 'self', no inline scripts, connect-src 'self', frame-ancestors 'none') makes the browser refuse anything else and stops other sites from framing the page. Referrer-Policy: no-referrer keeps the Link from being passed on when you click away, and API responses are never cached.
What I keep
- No logs. Worker logs and traces are switched off. Reveal sends the Secret ID in the request body, not the URL, so it doesn't end up in request URLs either.
- Counts. One event per created, revealed and expired Secret, in Cloudflare Workers Analytics Engine. It holds the event name and nothing else: no Secret ID, no IP, no content. That's how I know whether anyone uses this.
- A rate limit. 20 Secrets per hour per IP address. To count them, I keep an HMAC-SHA256 of your IP, keyed with a secret only my Worker holds, and a count, and delete both when the hour is up. Without that secret, nobody with my storage can turn the hash back into an IP by hashing every possible one.
- A size cap. Up to 10 KB of text, checked in your browser before encryption and again on the server.
Cloudflare runs all of this, so like any host it sees your IP address to deliver the page.
Check it yourself
The code is public at github.com/tonytangdev/secret, and each promise on this page has a test there. It's licensed under AGPL-3.0: anyone who runs a modified copy as a service has to publish their source too.