One-Time Secret
Self-destructing secret links: AES-GCM with Web Crypto, ciphertext in a Durable Object, one-time read then burn
Share a password or API token through a link that works once. The Worker encrypts the secret with a fresh AES-256-GCM key from Web Crypto. It stores only the ciphertext and IV in a dedicated Durable Object, schedules an alarm for expiry, and returns the key to you. The key travels in the URL fragment (/s/<id>#<key>), and browsers never send the fragment to the server.
Features
- Key never stored - Only ciphertext and IV are persisted. The key exists only in the share link
- One Durable Object per secret -
idFromName(<random id>)isolates each secret's storage and alarm - Read once, atomically - Decrypt and delete happen together inside the Durable Object under
blockConcurrencyWhile - Wrong key doesn't burn it - The secret is deleted only after a successful decrypt
- Bot-safe share page -
GET /s/:idshows a Reveal button, so link previews (Slack, iMessage) don't consume the secret - Alarm-based expiry - TTL from 1 minute to 7 days
API Reference
POST /secrets
Encrypt and store a secret.
Prop
Type
Example Request
curl -X POST "https://one-time-secret.your-subdomain.workers.dev/secrets" \
-H "Content-Type: application/json" \
-d '{"secret":"hunter2","ttlSeconds":3600}'Success Response (201)
{
"id": "9JvAypnZpBGIRSgd_QpH0A",
"key": "4WweMEdN0W5GWlWmC9jLxbJPeROm-eT5J1IM7VvzN3g",
"url": "https://one-time-secret.your-subdomain.workers.dev/s/9JvAypnZpBGIRSgd_QpH0A#4WweMEdN0W5GWlWmC9jLxbJPeROm-eT5J1IM7VvzN3g",
"expiresAt": "2026-10-01T17:40:45.486Z"
}Error Codes
400- Invalid JSON (INVALID_BODY), missing, empty, or too long secret (INVALID_SECRET), TTL out of range or not an integer (INVALID_TTL)
GET /secrets/:id
Check whether a secret still exists, without revealing or consuming it.
Success Response
{ "id": "9JvAypnZpBGIRSgd_QpH0A", "exists": true, "expiresAt": "2026-10-01T17:40:45.486Z" }After the secret is revealed or expires: { "id": "...", "exists": false, "expiresAt": null }.
Error Codes
400- Malformed id (INVALID_ID)
POST /secrets/:id/reveal
Decrypt and permanently delete the secret. The response has Cache-Control: no-store.
key string (required)
The base64url key from POST /secrets (the part of the share URL after #).
Example Request
curl -X POST "https://one-time-secret.your-subdomain.workers.dev/secrets/9JvAypnZpBGIRSgd_QpH0A/reveal" \
-H "Content-Type: application/json" \
-d '{"key":"4WweMEdN0W5GWlWmC9jLxbJPeROm-eT5J1IM7VvzN3g"}'Success Response
{ "secret": "hunter2" }Error Codes
400- Malformed id (INVALID_ID), invalid JSON (INVALID_BODY), malformed key or a key that doesn't decrypt this secret (INVALID_KEY). The secret is kept404- Already revealed, expired, or never existed (NOT_FOUND)
GET /s/:id
Serves a small self-contained HTML page. The page reads the key from location.hash, checks GET /secrets/:id, and calls POST /secrets/:id/reveal only when you click Reveal secret. The response sets a nonce-based Content-Security-Policy and the headers Cache-Control: no-store, Referrer-Policy: no-referrer, and X-Robots-Tag: noindex.
Error Codes
404- Malformed id (NOT_FOUND)
Architecture
POST /secrets ──► Worker: generate key + IV, AES-GCM encrypt
│ store({ ciphertext, iv, expiresAt }) (key NOT sent)
▼
SecretVault DO (idFromName(id)) ── setAlarm(expiresAt)
▲
POST /secrets/:id/reveal { key } ──► DO.reveal(key):
blockConcurrencyWhile {
load → decrypt with key → (ok) deleteAll + deleteAlarm
→ (fail) INVALID_KEY, keep
}
alarm() ──► deleteAllWhy decrypt inside the Durable Object?
If the Worker read and deleted the ciphertext first and decrypted afterward, a typo in the key would destroy the secret. Running the decrypt in the Durable Object lets it delete storage only after authenticated decryption succeeds. AES-GCM's auth tag guarantees that a wrong key is detected and never yields garbage plaintext.
The tradeoff is that the key passes through the Durable Object for the duration of the reveal() call. It is held in memory only and never written to storage.
decrypt() is not a storage operation, so Durable Object input gates alone would let a second concurrent reveal run between "read" and "delete". Wrapping reveal() in ctx.blockConcurrencyWhile() serializes reveals, so exactly one caller receives the plaintext.
Use Cases
- Send credentials to a teammate without leaving them in chat history
- Hand off API keys or recovery codes during onboarding
- Learn per-entity Durable Objects, RPC methods, and alarms in one small example
- Use as a starting point for burn-after-reading notes or one-time download links
Limitations
- Not end-to-end encrypted: the Worker generates the key and sees the plaintext at creation and reveal time. For zero-knowledge sharing, encrypt in the browser and send only ciphertext
- Anyone holding the full link can reveal the secret, so share it over a channel you trust
- Secrets are text only, up to 10,000 characters
- No rate limiting on reveal attempts (brute-forcing a 256-bit key is infeasible, but you may want to limit attempts anyway)
- Alarms fire at least once and may be slightly delayed. Reads also treat past-expiry records as gone
Use in your project
Copy these files into an existing Worker. Prefer Deployment to try the full experiment first. Source: apps/experiments/one-time-secret.
import { DurableObject } from "cloudflare:workers";import { STORAGE_KEY } from "./constants/defaults";import { decryptSecret } from "./lib/crypto";import type { Env } from "./types/env";import type { RevealResult, SecretStatus, StoredSecret } from "./types/secret";/** One instance per secret id. Stores only ciphertext + IV; the key arrives per reveal call and is never persisted. */export class SecretVault extends DurableObject<Env> { async store(record: StoredSecret): Promise<void> { if (await this.ctx.storage.get(STORAGE_KEY)) throw new Error("Secret id already in use"); await this.ctx.storage.put(STORAGE_KEY, record); await this.ctx.storage.setAlarm(record.expiresAt); } async status(): Promise<SecretStatus> { const record = await this.load(); return { exists: record !== null, expiresAt: record?.expiresAt ?? null }; } /** Decrypts and deletes in one step; a wrong key leaves the secret intact. */ async reveal(key: string): Promise<RevealResult> { // decrypt() is not a storage op, so input gates alone would let a concurrent reveal interleave // between read and delete. Blocking makes "read once" hold under concurrent requests. return this.ctx.blockConcurrencyWhile(async (): Promise<RevealResult> => { const record = await this.load(); if (!record) return { ok: false, code: "NOT_FOUND" }; const secret = await decryptSecret(record, key); if (secret === null) return { ok: false, code: "INVALID_KEY" }; await this.destroy(); return { ok: true, secret }; }); } async alarm(): Promise<void> { await this.destroy(); } private async load(): Promise<StoredSecret | null> { const record = await this.ctx.storage.get<StoredSecret>(STORAGE_KEY); if (!record) return null; if (record.expiresAt <= Date.now()) { await this.destroy(); return null; } return record; } private async destroy(): Promise<void> { await this.ctx.storage.deleteAlarm(); await this.ctx.storage.deleteAll(); }}Deployment
Deploy
wrangler.json declares the SECRETS Durable Object binding and the new_sqlite_classes migration for SecretVault. No other setup is needed.
Test your deployment
curl -X POST "https://one-time-secret.your-subdomain.workers.dev/secrets" \
-H "Content-Type: application/json" \
-d '{"secret":"hello","ttlSeconds":600}'Open the returned url in a browser and click Reveal secret. Open it again and you'll see that it's gone.
Local Development
cd apps/experiments/one-time-secret
npm install
npm run devcurl -X POST "http://localhost:8787/secrets" \
-H "Content-Type: application/json" \
-d '{"secret":"hunter2","ttlSeconds":120}'Configuration
| Binding | Purpose |
|---|---|
SECRETS | Durable Object namespace (SecretVault, SQLite-backed, RPC) |
Cloudflare Features Used
- Durable Objects - One instance per secret, with RPC methods
store,status, andreveal - Durable Object Alarms - Delete the secret at expiry
blockConcurrencyWhile- Serializes reveals so a secret is read exactly once- Web Crypto - AES-256-GCM encryption and decryption