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-sidereadIdin the path and the client-sideaccessSecretin 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
readIdvalues and atrackId, then marks the secret asready.
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-byterandom value encoded into theURL fragment, not into API requests. -
HKDFcontexts separate text keys, file keys, manifest keys, and chunk nonce derivation. -
AES-GCMauthenticates ciphertext, metadata context, and chunk placement through associated data, matching the browser AesGcmParams model. -
readId,trackId,secretId, anduploadTokenvalues 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:
- CWE-312: Cleartext Storage of Sensitive Information
- CWE-319: Cleartext Transmission of Sensitive Information
- CWE-359: Exposure of Private Personal Information
- NIST SP 800-57 Part 1: Recommendation for Key Management
- NIST SP 800-90A Rev. 1: Recommendation for Random Number Generation
- WHATWG Encoding Standard