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

Minimal diagram of the browser, cipher package, API, and storage layers.
The browser owns the key; the API and storage layers only coordinate encrypted payloads and lifecycle state.

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.

Minimal diagram of a file becoming an encrypted manifest and encrypted chunks.
File metadata becomes an encrypted manifest, while file bytes move as separate encrypted chunks.

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.

ETH signature verification

ETH verification adds an opener-authentication boundary in front of a read link. Its purpose is not to encrypt or decrypt the secret and it does not change the file key, text key, ciphertext, or browser-side decryption path. Instead, it asks the person opening the link to prove current control of the Ethereum mainnet address selected by the sender before the server releases the underlying readId.

Minimal diagram of a wallet signature passing an access gate before a read token.
The wallet signature gates access to a read link; it does not become part of the encryption material.

Conceptually, this is a possession check: the opener signs a short-lived wallet message, the server verifies that the signature was produced by the expected address, and only then is the normal encrypted read flow allowed to continue. The signature is therefore an authorization signal for opening the secret, not cryptographic material used by AES-GCM, HKDF, or file chunk encryption.

Signed message scope

The message follows the intent of Sign-In with Ethereum: make wallet authentication human-readable, domain-scoped, and machine-verifiable instead of asking a user to sign opaque bytes. It carries the expected address, chain, domain, URI, statement, nonce, issuance time, and expiration time. Those fields make the signature meaningful only for this opening attempt, this link, and this browser origin. The underlying signed-message format is also designed to be distinct from Ethereum transactions, as described by ERC-191.

Security value and limits

This is especially useful when transport trust is imperfect. TLS is still required, but a network intermediary, proxy, or compromised TLS path should not be able to silently turn an ETH-gated link into a usable read link without a valid wallet signature for the configured address. OWASP describes improper certificate validation and other machine-in-the-middle risks as attacks on transport trust; ETH verification adds a separate application-layer proof that the opener intentionally participated in this specific access attempt.

The boundary is deliberately narrow. ETH verification can confirm control of a wallet address at the time of opening, reduce replay by using expiring single-use challenges, and bind the signed intent to the current domain and URI. It does not identify the human behind the wallet, prevent a valid wallet owner from forwarding plaintext after decryption, protect a compromised browser or wallet, or replace HTTPS and careful certificate validation. It is an access-control check for who may open the secret, not a second encryption scheme for the secret itself.

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

Design

Every visual decision in Secret is generated from unUI. The page structure, hierarchy, illustration direction, and interface details begin as unUI design evidence before they are shaped into the product experience.

This keeps the design source explicit and traceable: unUI generates the design language, while Secret turns that generated direction into a stable, maintainable product page.

Design evidence from unUI