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?

  1. Unfurl bots only make GET requests. A GET-based consume would lose the secret to Slack & co.
  2. Browser prefetch can speculatively trigger GET requests. A POST is never prefetched.
  3. 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 scripts
  • frame-ancestors 'none' — the page cannot be embedded in an iframe
  • Cache-Control: no-store — no caching of the ciphertext in browsers or proxies
  • Strict-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.

Glossary

End-to-End Encryption
A communication principle where only the sender and recipient can read the message. The transport path — including the server — only sees encrypted data and has no access to the key.
AES-256-GCM
An authenticated encryption mode combining AES with a 256-bit key and Galois/Counter Mode. Provides both confidentiality and integrity protection — tampered ciphertexts are detected during decryption.
URL Fragment
The part of a URL after the # character. Per the HTTP specification, it is never sent to the server and is only processed client-side by the browser. This makes it suitable as a channel for data the server should not see.
Base64url
A URL-safe variant of Base64 encoding that replaces + with - and / with _. Can be used directly in URLs without URL encoding.
WebCrypto API
A browser API for cryptographic operations such as encryption, hashing, and key generation. Runs natively in the browser without external libraries and supports AES-GCM, RSA, ECDSA, and more.
Atomic Operation
An operation that either completes fully or not at all — with no intermediate states. In databases, atomicity prevents parallel requests from producing inconsistent results.
Content Security Policy
An HTTP header that tells the browser which resources a page is allowed to load. script-src 'self' allows only scripts from the same domain, blocking injected code (XSS).