# One-Time Secret

URL: /docs/experiments/one-time-secret
> Self-destructing secret links: AES-GCM with Web Crypto, ciphertext in a Durable Object, one-time read then burn
Tags: stateful, security, durable-objects, secrets, encryption, one-time
Bindings: DO
Markdown: /llms.mdx/experiments/one-time-secret/content.md



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 [#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 [#api-reference]

### POST /secrets [#post-secrets]

Encrypt and store a secret.

<TypeTable
  type="{
  secret: {
    description: &#x22;The secret text (1–10,000 characters).&#x22;,
    type: &#x22;string&#x22;,
    required: true,
  },
  ttlSeconds: {
    description: &#x22;Lifetime in seconds, integer 60–604800. Defaults to 86400 (24 hours).&#x22;,
    type: &#x22;number&#x22;,
    required: false,
  },
}"
/>

#### Example Request [#example-request]

```bash
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) [#success-response-201]

```json
{
  "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 [#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 [#get-secretsid]

Check whether a secret still exists, without revealing or consuming it.

#### Success Response [#success-response]

```json
{ "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 [#error-codes-1]

* `400` - Malformed id (`INVALID_ID`)

### POST /secrets/:id/reveal [#post-secretsidreveal]

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 [#example-request-1]

```bash
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 [#success-response-1]

```json
{ "secret": "hunter2" }
```

#### Error Codes [#error-codes-2]

* `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 [#get-sid]

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 [#error-codes-3]

* `404` - Malformed id (`NOT_FOUND`)

## Architecture [#architecture]

```text
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? [#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 [#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 [#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 [#use-in-your-project]

<UseInYourProject
  experiment="one-time-secret"
  bindings="[&#x22;Durable Objects (SECRETS → SecretVault, new_sqlite_classes)&#x22;]"
  platform="[&#x22;Web Crypto (AES-GCM)&#x22;, &#x22;Durable Object RPC + Alarms&#x22;]"
  files="[
  &#x22;src/secret-vault.ts&#x22;,
  &#x22;src/lib/crypto.ts&#x22;,
  &#x22;src/lib/encoding.ts&#x22;,
  &#x22;src/types/secret.ts&#x22;,
  &#x22;src/constants/defaults.ts&#x22;,
  &#x22;examples/usage.ts&#x22;,
]"
  sources="[{&#x22;path&#x22;:&#x22;src/secret-vault.ts&#x22;,&#x22;title&#x22;:&#x22;secret-vault.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import { DurableObject } from \&#x22;cloudflare:workers\&#x22;;\nimport { STORAGE_KEY } from \&#x22;./constants/defaults\&#x22;;\nimport { decryptSecret } from \&#x22;./lib/crypto\&#x22;;\nimport type { Env } from \&#x22;./types/env\&#x22;;\nimport type { RevealResult, SecretStatus, StoredSecret } from \&#x22;./types/secret\&#x22;;\n\n/** One instance per secret id. Stores only ciphertext + IV; the key arrives per reveal call and is never persisted. */\nexport class SecretVault extends DurableObject<Env> {\n  async store(record: StoredSecret): Promise<void> {\n    if (await this.ctx.storage.get(STORAGE_KEY)) throw new Error(\&#x22;Secret id already in use\&#x22;);\n    await this.ctx.storage.put(STORAGE_KEY, record);\n    await this.ctx.storage.setAlarm(record.expiresAt);\n  }\n\n  async status(): Promise<SecretStatus> {\n    const record = await this.load();\n    return { exists: record !== null, expiresAt: record?.expiresAt ?? null };\n  }\n\n  /** Decrypts and deletes in one step; a wrong key leaves the secret intact. */\n  async reveal(key: string): Promise<RevealResult> {\n    // decrypt() is not a storage op, so input gates alone would let a concurrent reveal interleave\n    // between read and delete. Blocking makes \&#x22;read once\&#x22; hold under concurrent requests.\n    return this.ctx.blockConcurrencyWhile(async (): Promise<RevealResult> => {\n      const record = await this.load();\n      if (!record) return { ok: false, code: \&#x22;NOT_FOUND\&#x22; };\n\n      const secret = await decryptSecret(record, key);\n      if (secret === null) return { ok: false, code: \&#x22;INVALID_KEY\&#x22; };\n\n      await this.destroy();\n      return { ok: true, secret };\n    });\n  }\n\n  async alarm(): Promise<void> {\n    await this.destroy();\n  }\n\n  private async load(): Promise<StoredSecret | null> {\n    const record = await this.ctx.storage.get<StoredSecret>(STORAGE_KEY);\n    if (!record) return null;\n    if (record.expiresAt <= Date.now()) {\n      await this.destroy();\n      return null;\n    }\n    return record;\n  }\n\n  private async destroy(): Promise<void> {\n    await this.ctx.storage.deleteAlarm();\n    await this.ctx.storage.deleteAll();\n  }\n}&#x22;},{&#x22;path&#x22;:&#x22;src/lib/crypto.ts&#x22;,&#x22;title&#x22;:&#x22;crypto.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import { ID_BYTES, IV_BYTES, KEY_BYTES } from \&#x22;../constants/defaults\&#x22;;\nimport type { EncryptedSecret } from \&#x22;../types/secret\&#x22;;\nimport { fromBase64Url, toBase64Url } from \&#x22;./encoding\&#x22;;\n\nconst ALGORITHM = \&#x22;AES-GCM\&#x22;;\n\nexport function generateId(): string {\n  return toBase64Url(crypto.getRandomValues(new Uint8Array(ID_BYTES)));\n}\n\n/** Encrypts with a fresh random AES-256-GCM key. The returned `key` must never be stored server-side. */\nexport async function encryptSecret(plaintext: string): Promise<EncryptedSecret & { key: string }> {\n  const rawKey = crypto.getRandomValues(new Uint8Array(KEY_BYTES));\n  const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));\n  const key = await crypto.subtle.importKey(\&#x22;raw\&#x22;, rawKey, ALGORITHM, false, [\&#x22;encrypt\&#x22;]);\n  const ciphertext = await crypto.subtle.encrypt(\n    { name: ALGORITHM, iv },\n    key,\n    new TextEncoder().encode(plaintext)\n  );\n  return {\n    key: toBase64Url(rawKey),\n    iv: toBase64Url(iv),\n    ciphertext: toBase64Url(new Uint8Array(ciphertext)),\n  };\n}\n\n/** Returns the plaintext, or null if the key is malformed or does not authenticate the ciphertext. */\nexport async function decryptSecret(\n  encrypted: EncryptedSecret,\n  key: string\n): Promise<string | null> {\n  const rawKey = fromBase64Url(key);\n  const iv = fromBase64Url(encrypted.iv);\n  const ciphertext = fromBase64Url(encrypted.ciphertext);\n  if (!rawKey || rawKey.length !== KEY_BYTES || !iv || !ciphertext) return null;\n  try {\n    const cryptoKey = await crypto.subtle.importKey(\&#x22;raw\&#x22;, rawKey, ALGORITHM, false, [\&#x22;decrypt\&#x22;]);\n    const plaintext = await crypto.subtle.decrypt({ name: ALGORITHM, iv }, cryptoKey, ciphertext);\n    return new TextDecoder().decode(plaintext);\n  } catch {\n    return null;\n  }\n}&#x22;},{&#x22;path&#x22;:&#x22;src/lib/encoding.ts&#x22;,&#x22;title&#x22;:&#x22;encoding.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;export function toBase64Url(bytes: Uint8Array): string {\n  let binary = \&#x22;\&#x22;;\n  for (const byte of bytes) binary += String.fromCharCode(byte);\n  return btoa(binary).replace(/\\+/g, \&#x22;-\&#x22;).replace(/\\//g, \&#x22;_\&#x22;).replace(/=+$/, \&#x22;\&#x22;);\n}\n\n/** Returns null for anything that is not valid unpadded base64url. */\nexport function fromBase64Url(input: string): Uint8Array | null {\n  if (!/^[A-Za-z0-9_-]*$/.test(input)) return null;\n  try {\n    const binary = atob(input.replace(/-/g, \&#x22;+\&#x22;).replace(/_/g, \&#x22;/\&#x22;));\n    return Uint8Array.from(binary, (c) => c.charCodeAt(0));\n  } catch {\n    return null;\n  }\n}&#x22;},{&#x22;path&#x22;:&#x22;src/types/secret.ts&#x22;,&#x22;title&#x22;:&#x22;secret.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;export interface EncryptedSecret {\n  /** base64url AES-GCM ciphertext (includes the auth tag) */\n  ciphertext: string;\n  /** base64url 96-bit IV */\n  iv: string;\n}\n\nexport interface StoredSecret extends EncryptedSecret {\n  /** Unix epoch milliseconds */\n  expiresAt: number;\n}\n\nexport type RevealResult =\n  | { ok: true; secret: string }\n  | { ok: false; code: \&#x22;NOT_FOUND\&#x22; | \&#x22;INVALID_KEY\&#x22; };\n\nexport interface SecretStatus {\n  exists: boolean;\n  expiresAt: number | null;\n}\n\nexport interface CreateSecretRequest {\n  secret?: unknown;\n  ttlSeconds?: unknown;\n}\n\nexport interface CreateSecretResponse {\n  id: string;\n  key: string;\n  url: string;\n  expiresAt: string;\n}\n\nexport interface SecretStatusResponse {\n  id: string;\n  exists: boolean;\n  expiresAt: string | null;\n}&#x22;},{&#x22;path&#x22;:&#x22;src/constants/defaults.ts&#x22;,&#x22;title&#x22;:&#x22;defaults.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;export const MAX_SECRET_LENGTH = 10_000;\nexport const MIN_TTL_SECONDS = 60;\nexport const MAX_TTL_SECONDS = 604_800;\nexport const DEFAULT_TTL_SECONDS = 86_400;\n\nexport const ID_BYTES = 16;\nexport const KEY_BYTES = 32;\nexport const IV_BYTES = 12;\n\nexport const STORAGE_KEY = \&#x22;secret\&#x22;;&#x22;},{&#x22;path&#x22;:&#x22;examples/usage.ts&#x22;,&#x22;title&#x22;:&#x22;usage.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;/**\n * Minimal paste example for docs (\&#x22;Use in your project\&#x22;).\n * Not part of the Worker runtime — copy into your own project.\n *\n * Needs a Durable Object binding SECRETS -> SecretVault (migration: new_sqlite_classes).\n * The key is returned to the caller only; the Durable Object stores ciphertext + IV.\n */\nimport { encryptSecret, generateId } from \&#x22;../src/lib/crypto\&#x22;;\nimport type { Env } from \&#x22;../src/types/env\&#x22;;\n\nexport { SecretVault } from \&#x22;../src/secret-vault\&#x22;;\n\nexport default {\n  async fetch(request: Request, env: Env): Promise<Response> {\n    const url = new URL(request.url);\n    const vault = (id: string) => env.SECRETS.get(env.SECRETS.idFromName(id));\n\n    if (request.method === \&#x22;POST\&#x22; && url.pathname === \&#x22;/secrets\&#x22;) {\n      const { secret } = (await request.json()) as { secret: string };\n      const id = generateId();\n      const { key, ciphertext, iv } = await encryptSecret(secret);\n      await vault(id).store({ ciphertext, iv, expiresAt: Date.now() + 3600_000 });\n      return Response.json({ id, key }, { status: 201 });\n    }\n\n    const reveal = url.pathname.match(/^\\/secrets\\/([\\w-]+)\\/reveal$/);\n    if (request.method === \&#x22;POST\&#x22; && reveal) {\n      const { key } = (await request.json()) as { key: string };\n      const result = await vault(reveal[1]).reveal(key);\n      return result.ok\n        ? Response.json({ secret: result.secret })\n        : Response.json({ code: result.code }, { status: result.code === \&#x22;NOT_FOUND\&#x22; ? 404 : 400 });\n    }\n\n    return new Response(\&#x22;Not found\&#x22;, { status: 404 });\n  },\n};&#x22;}]"
/>

## Deployment [#deployment]

<Steps>
  <Step>
    ### Click the deploy button [#click-the-deploy-button]

    [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/shrinathsnayak/cloudflare-experiments/tree/main/apps/experiments/one-time-secret)
  </Step>

  <Step>
    ### Deploy [#deploy]

    `wrangler.json` declares the `SECRETS` Durable Object binding and the `new_sqlite_classes` migration for `SecretVault`. No other setup is needed.
  </Step>

  <Step>
    ### Test your deployment [#test-your-deployment]

    ```bash
    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.
  </Step>
</Steps>

## Local Development [#local-development]

```bash
cd apps/experiments/one-time-secret
npm install
npm run dev
```

```bash
curl -X POST "http://localhost:8787/secrets" \
  -H "Content-Type: application/json" \
  -d '{"secret":"hunter2","ttlSeconds":120}'
```

## Configuration [#configuration]

| Binding   | Purpose                                                      |
| --------- | ------------------------------------------------------------ |
| `SECRETS` | Durable Object namespace (`SecretVault`, SQLite-backed, RPC) |

## Cloudflare Features Used [#cloudflare-features-used]

* **[Durable Objects](https://developers.cloudflare.com/durable-objects/)** - One instance per secret, with RPC methods `store`, `status`, and `reveal`
* **[Durable Object Alarms](https://developers.cloudflare.com/durable-objects/api/alarms/)** - Delete the secret at expiry
* **[`blockConcurrencyWhile`](https://developers.cloudflare.com/durable-objects/api/state/#blockconcurrencywhile)** - Serializes reveals so a secret is read exactly once
* **[Web Crypto](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/)** - AES-256-GCM encryption and decryption

## Next Steps [#next-steps]

<Cards>
  <Card title="DO Alarm Scheduler" href="/experiments/do-alarm-scheduler">
    The Durable Object Alarm API on its own
  </Card>

  <Card title="Durable Counter" href="/experiments/durable-counter">
    Minimal Durable Objects reference
  </Card>
</Cards>
