Secret is a zero-knowledge sharing tool for short-lived private data. The browser encrypts the content before it touches the network, and the server only coordinates ciphertext, storage, expiration, and one-time read tokens. The decryption secret stays in the URL fragment, which browsers do not send in HTTP requests.

The result is a link that can be shared like an ordinary URL, while the actual plaintext remains recoverable only by someone who has both parts: the server-side readId in the path and the client-side accessSecret in the hash.

Secret also issues a separate tracking link so the sender can see whether a read link is still available, consumed, expired, or destroyed.

System architecture

Secret is split into three layers. The portal is a browser client that owns the user experience and calls the cipher package locally. The cipher package contains the portable cryptography primitives for text and files. It is built on the standard Web Cryptography API surface instead of implementing its own cipher primitives. The edge package is a Cloudflare Worker API backed by D1 for metadata and read tokens, plus R2 for encrypted file objects.

browser client
  -> cipher package (Web Crypto, HKDF-SHA256, AES-GCM)
  -> edge Worker API
      -> D1: metadata, read ids, tracking state
      -> R2: encrypted file bytes

The API never performs decryption. It validates limits, creates random ids, stores encrypted payloads, consumes read links, and deletes expired content. This boundary is intentional: a database dump, object-store leak, proxy log, or server-side bug should not reveal plaintext because the required secret is not stored there.

Text secret flow

For text, the browser generates a 32-byte access secret and encrypts the UTF-8 payload locally. Random values come from Crypto.getRandomValues: a 16-byte salt and 12-byte IV are generated for the payload, and HKDF-SHA256 derives a 32-byte AES key from the access secret. AES-GCM encrypts and authenticates the message with a text-specific additional authenticated data context.

secret = random(32 bytes)
salt   = random(16 bytes)
iv     = random(12 bytes)
key    = HKDF-SHA256(secret, salt, "secret-cipher:text-key")
cipher = AES-GCM.encrypt(key, iv, plaintext, "secret-cipher:text")

The ciphertext is serialized as a compact Base64URL payload in the format salt.iv.ciphertext. Only that serialized ciphertext and its size are posted to the Worker. The Worker stores it in D1, creates one or more random readId values, creates one random trackId, and returns those ids to the portal.

POST /secrets/text
{
  "cipher": "salt.iv.ciphertext",
  "plainSize": 1234,
  "reads": 1,
  "expiresInSeconds": 3600
}

File secret flow

Files use the same zero-knowledge model, but the data is split into an encrypted manifest and encrypted chunks. The manifest contains file metadata such as name, type, size, chunkSize, and chunkCount. It is encrypted separately from the file bytes, so even file metadata is not stored in plaintext by the API.

Chunks default to 8 MiB. The file key and manifest key are derived from the same access secret with different HKDF contexts. Each chunk uses a deterministic nonce derived from a secret nonce base and the chunkIndex, keeping chunk nonces separate to avoid the nonce reuse failure mode in AEAD schemes. AES-GCM authenticates the chunk index, total chunk count, chunk size, and plaintext length as additional data.

manifestKey = HKDF-SHA256(secret, salt, "secret-cipher:file-manifest-key")
fileKey     = HKDF-SHA256(secret, salt, "secret-cipher:file-key")
nonceBase   = HKDF-SHA256(secret, salt, "secret-cipher:file-chunk-nonce")
chunkNonce  = nonceBase XOR chunkIndex
  • The browser encrypts the manifest and file chunks before upload.
  • The Worker creates a pending upload session and returns a presigned R2 upload URL.
  • The browser uploads encrypted bytes directly to R2 and then completes the session with the uploadToken.
  • Completion creates readId values and a trackId, then marks the secret as ready.
init file -> encrypted manifest stored in D1
upload    -> encrypted chunks stored in R2
complete  -> read ids + track id become active

Access URL architecture

A readable Secret URL has two different security domains in one string. The path contains the readId, which the server needs in order to locate and consume the encrypted record. The hash fragment contains the accessSecret, which the server must not receive. This is the central reason Secret uses the fragment boundary defined by URI syntax instead of query parameters.

https://secret.example/s/{readId}#{accessSecret}
                        ^^^^^^^^ ^^^^^^^^^^^^^^
                        server   browser-only fragment

When a recipient opens the link, the portal reads the fragment locally, asks the Worker for the encrypted secret by readId, then decrypts in the browser. For text secrets, the readId is consumed before ciphertext is returned. For file secrets, the metadata request reveals only encrypted manifest data, and the readId is consumed when the encrypted object is downloaded.

Read, burn, and tracking

Read links are independent consumable tokens stored in D1. Each readId can be used once. The Worker checks the row, rejects already consumed links, rejects expired records, atomically marks the readId as consumed, and then returns the encrypted payload. When no reads remain, the secret content is destroyed while short-lived tracking metadata is kept.

readId -> find secret
       -> reject consumed / expired / not ready
       -> consume readId
       -> return ciphertext or encrypted file stream
       -> destroy content when remainingReads == 0

Tracking uses a separate trackId. It reports kind, lifecycle status, created time, completed time, expiration time, destroyed time, read limit, remaining reads, and per-read consumption timestamps. It does not include plaintext, ciphertext, access secrets, upload tokens, or file objects.

Lifecycle limits

Secret is deliberately short-lived. Text payloads are limited to 50 KB of plaintext and 70 KB of serialized ciphertext. A secret can create up to 10 read links and can live for at most 1 hour. Pending file uploads are removed after 15 minutes if they are not completed. Destroyed tracking records are retained for up to 24 hours and then deleted.

max text plaintext: 50 KB
max text ciphertext: 70 KB
max reads: 10
max expiration: 1 hour
pending upload ttl: 15 minutes
tracking ttl after destroy/expire: 24 hours

A scheduled cleanup job destroys ready records after expiration, deletes stale pending uploads, removes R2 objects when needed, and finally removes tracking-expired records from D1.

Security model

Secret protects against the server, database, object store, network intermediaries, and accidental logs learning the plaintext, assuming the browser runtime and recipient device are trustworthy. The important properties are:

  • Plaintext is encrypted before upload and decrypted only after download in the browser.
  • The access secret is a 32-byte random value encoded into the URL fragment, not into API requests.
  • HKDF contexts separate text keys, file keys, manifest keys, and chunk nonce derivation.
  • AES-GCM authenticates ciphertext, metadata context, and chunk placement through associated data, matching the browser AesGcmParams model.
  • readId, trackId, secretId, and uploadToken values are random and independent.
  • Server-side expiration and consumption rules reduce the time window in which encrypted material exists.

The model does not protect against a compromised browser, malicious extensions, clipboard theft, screen recording, phishing, a recipient who copies the plaintext after opening it, or a shared link being forwarded before it is consumed. Secret reduces server-side and network exposure; it does not make an untrusted endpoint trustworthy.

Imported working keys are created as non-extractable CryptoKey values, so Web Crypto can use them for deriveBits, encrypt, or decrypt without exposing key bytes through the API. AES-GCM encryption and decryption also use a 128-bit authentication tag.

This boundary follows the same practical warning in the OWASP Secrets Management Cheat Sheet: client-side encryption can keep data encrypted until active decryption, but it cannot rescue a compromised endpoint. HTTPS remains required for transport, but the TLS layer is not treated as a substitute for client-side encryption.

The storage rule is intentionally conservative: store ciphertext, keep access secrets out of server persistence, and delete material when the read or expiration lifecycle allows it. This is aligned with OWASP's cryptographic storage guidance. Malformed ciphertext, malformed fragments, and invalid encoded bytes are rejected before decryption, following the same fail-closed posture expected by input validation guidance.

Further reading

These references are useful for reviewing the risk categories behind Secret's storage and transmission boundaries:

  1. CWE-312: Cleartext Storage of Sensitive Information
  2. CWE-319: Cleartext Transmission of Sensitive Information
  3. CWE-359: Exposure of Private Personal Information
  4. NIST SP 800-57 Part 1: Recommendation for Key Management
  5. NIST SP 800-90A Rev. 1: Recommendation for Random Number Generation
  6. WHATWG Encoding Standard