Passwords via Slack. API keys via email. SSH keys as a Teams message. We all know this is a bad idea — and we do it anyway. Because it’s fast. Because the alternative is cumbersome.
One-time secret services solve this problem: a secret is wrapped into a link that can be opened exactly once. After that, it’s gone. But most of these services have a fundamental problem: the server sees the plaintext.
The Threat Model
Before writing code, it’s worth asking: who are we actually protecting against?
The Unfurl Bot as an Attacker
Slack, Teams, and Discord automatically fetch URLs to display a preview. A naive one-time secret service that delivers the secret on GET loses it to the bot before the recipient ever sees it.
Solution: The reveal page (GET /s/:id) only shows a button. The secret is delivered only on POST /api/secrets/:id/consume. Bots don’t make POST requests.
The Compromised Server
If the server knows the plaintext, a database breach by an attacker is equivalent to accessing all secrets. Server-side encryption doesn’t help — the server has the key.
Solution: End-to-end encryption . The client encrypts, the server stores only ciphertext, the recipient’s browser decrypts. The server never sees the key.
Race Conditions on Consume
Two requests arrive simultaneously. Without atomicity, both get the ciphertext — the “one-time” in one-time secret is broken.
Solution: Atomic UPDATE ... RETURNING in SQLite. Only the first request that sets consumed_at from NULL to a timestamp gets the data back.
The E2E Design
Key Transport via URL Fragments
The URL fragment (everything after #) is never sent to the server according to the HTTP specification. Browsers process it purely client-side. This makes it the ideal channel for the encryption key:
https://once.mathis-adler.dev/s/dG9rZW4xMjM0NTY3OA#SchlüsselHierBase64url
├── ID (Server sieht) ─┤├── Key (nur Browser) ──┤
The server receives the ID to find the ciphertext. It never sees the key needed for decryption.
The Crypto Core
The encryption uses AES-256-GCM — an authenticated encryption mode that simultaneously guarantees confidentiality and integrity:
- 256-bit key — randomly generated, 2^256 possible combinations
- 96-bit IV (Initialization Vector) — unique per encryption
- 128-bit Authentication Tag — ensures the ciphertext has not been tampered with
The encrypted output is packed as IV (12 bytes) || Ciphertext || Auth-Tag (16 bytes) and encoded as Base64url . The CLI encrypts with Node.js crypto, the browser decrypts with the WebCrypto API .
POST Instead of GET
Why is the consume endpoint a POST and not a GET?
- Unfurl bots only make GET requests. A GET-based consume would lose the secret to Slack & co.
- Browser prefetch can speculatively trigger GET requests. A POST is never prefetched.
- Semantics: consuming a secret is a state-changing operation. POST is the correct HTTP verb for that.
Header Hygiene
Even with E2E encryption, an XSS attack on the reveal page could exfiltrate the key from window.location.hash. The Content Security Policy prevents this:
Content-Security-Policy: default-src 'self'; script-src 'self'; frame-ancestors 'none'
script-src 'self'— no inline JavaScript, no external scriptsframe-ancestors 'none'— the page cannot be embedded in an iframeCache-Control: no-store— no caching of the ciphertext in browsers or proxiesStrict-Transport-Security— enforces HTTPS for 2 years
Each header closes a specific attack vector. None of them are optional.
Conclusion
A one-time secret service where the server knows the plaintext is better than passwords via Slack — but it merely shifts the trust problem. End-to-end encryption with URL fragments as key transport solves this problem elegantly: the server becomes a pure ciphertext store, and even a complete compromise reveals no secrets.
The most important design decisions have nothing to do with cryptography: POST instead of GET against unfurl bots, atomic consume against race conditions, strict CSP against XSS. Security doesn’t come from a single measure but from the sum of well-considered decisions.