What is this?

A service for sharing secrets once — passwords, API keys, private messages. Instead of sending secrets in plaintext via chat or email, Once generates a link that can be opened exactly once. After that, the secret is irrevocably deleted.

The key feature: end-to-end encryption ensures that the server never has access to the plaintext at any point. The encryption key is embedded in the URL fragment (#key) and is never sent to the server.

How It Works

  1. CLI encrypts the secret locally with a random AES-256-GCM key
  2. Server stores only the ciphertext with a public ID
  3. Link is generated: once.mathis-adler.dev/s/<id>#<key> — the key in the fragment never leaves the client
  4. Recipient opens the link, clicks “Reveal”, the browser fetches the ciphertext and decrypts it via WebCrypto
  5. Secret is consumed atomically on first retrieval, then 410 Gone

Security Decisions

  • Zero-knowledge architecture — The server never sees the key or plaintext. Even in the event of a server compromise, the secrets remain protected.
  • POST instead of GET for consuming — Unfurl bots in Slack, Teams, or Discord only make GET requests. The reveal page (GET /s/:id) only shows the “Reveal” button without consuming the secret. Only the explicit POST /api/secrets/:id/consume returns the ciphertext.
  • Atomic consume — SQLite’s UPDATE ... RETURNING ensures that with concurrent requests, only the first one receives the ciphertext.
  • Content Security Policy script-src 'self' prevents injected code from exfiltrating the key from the URL fragment.
  • 128-bit IDs — 22-character Base64url (~2^128 combinations) make brute-force attacks on IDs infeasible.
  • Two-tier rate limiting (Nginx + Express) — stricter for consume requests and invalid IDs.
  • Automatic cleanup — Expired secrets are deleted immediately; consumed ones after a one-hour grace period.

What I Learned

  • URL fragments (#...) are never sent to the server by the browser — making them ideal for client-side keys. This is not a hack but part of the HTTP specification (RFC 3986).
  • The WebCrypto API is powerful, but its ergonomics require care: key import, IV handling, and the correct separation of IV, ciphertext, and auth tag must be exact.
  • “Read once, then delete” sounds simple, but atomicity is critical. Without UPDATE ... RETURNING, there would be a race condition window where two requests could simultaneously receive the ciphertext.

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).
Rate Limiting
A protection mechanism that limits the number of requests per time period. Prevents brute-force attacks and token enumeration through automated queries.