This site is not affiliated with or endorsed by Cloudflare, Inc. It showcases deployable experiments across most Cloudflare products.
Stateful & Async

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/:id shows 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 kept
  • 404 - 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() ──► deleteAll

Why 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.

DependenciesNoneBindingsDurable Objects (SECRETS → SecretVault, new_sqlite_classes)PlatformWeb Crypto (AES-GCM), Durable Object RPC + Alarms
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

Click the deploy button

Deploy to Cloudflare Workers

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 dev
curl -X POST "http://localhost:8787/secrets" \
  -H "Content-Type: application/json" \
  -d '{"secret":"hunter2","ttlSeconds":120}'

Configuration

BindingPurpose
SECRETSDurable Object namespace (SecretVault, SQLite-backed, RPC)

Cloudflare Features Used

Next Steps

On this page