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
- CLI encrypts the secret locally with a random AES-256-GCM key
- Server stores only the ciphertext with a public ID
- Link is generated:
once.mathis-adler.dev/s/<id>#<key>— the key in the fragment never leaves the client - Recipient opens the link, clicks “Reveal”, the browser fetches the ciphertext and decrypts it via WebCrypto
- 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 explicitPOST /api/secrets/:id/consumereturns the ciphertext. - Atomic consume — SQLite’s
UPDATE ... RETURNINGensures 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.