← Create a secret

🔐 How it works

Security model, encryption details, and the full flow — for those who want to know.

The full flow

1
You type your secret

Text never leaves your browser in plain form. Nothing is sent to the server at this point.

2
Your browser encrypts it

A random 256-bit key and a random 96-bit IV are generated locally. AES-256-GCM encryption runs entirely in your browser using the native Web Crypto API — no libraries, no third parties.

3
Only the ciphertext goes to the server

The server receives an encrypted blob it cannot read. The encryption key stays in your browser and is embedded in the link's #fragment — the part after # that browsers never include in HTTP requests.

4
You get a one-time link

The server returns a random ID. Your browser assembles the full link: /view/<id>#<key>. Share only this complete link.

5
Recipient opens the link

Their browser extracts the key from the #fragment, fetches the ciphertext by ID, and decrypts locally. The server deletes the record before responding — the secret is gone from storage the instant it is retrieved.

6
Gone forever

Any subsequent request for the same ID returns 404. Even the server operator cannot retrieve a secret that has been viewed. Unviewed secrets expire automatically after 1 hour, 24 hours, or 7 days — whichever you chose.


The link anatomy

Here is what a generated link looks like and why each part matters:

https://example.com/view/550e8400-e29b-41d4-a716-446655440000#k3yB64urlEncodedHere_43chars
Domain — sent to server Secret ID — sent to server (identifies the ciphertext row) # — fragment boundary, never sent in HTTP requests Encryption key — stays in the browser only

Cipher specification

Algorithm
AES-256-GCM Authenticated encryption — any tampering with the ciphertext is detected and rejected
Key size
256 bits (32 bytes) Generated fresh for every secret via crypto.getRandomValues()
IV / Nonce
96 bits (12 bytes) Random per encryption — prepended to the payload so the recipient can decrypt
Auth tag
128 bits (16 bytes) GCM mode appends this automatically. Decryption fails hard if it doesn't match
Payload layout
IV (12 B) ‖ Ciphertext ‖ GCM tag (16 B) Base64url-encoded, stored in the database, sent to recipient's browser
Key encoding
Base64url, 43 characters, no padding URL-safe — embedded in the #fragment, never transmitted to the server
Crypto engine
Web Crypto API (SubtleCrypto) Browser-native, zero dependencies, FIPS 140-2 compliant in most implementations
Secure context
HTTPS or localhost required Browsers disable crypto.subtle on plain HTTP — by design

Server & storage

The server is a minimal Node.js / Express application. It stores encrypted blobs in a SQLite database — a single file on disk. This means secrets survive server restarts, unlike an in-memory store.

What the database row contains: a random UUID, the encrypted blob (meaningless without the key), and an expiry timestamp. No IP addresses, no user agents, no plaintext, no keys — ever.

Expired secrets are purged on every server startup and once per hour thereafter. The delete-before-respond design means the record is gone before the HTTP response is sent — even a simultaneous second request will get a 404.


Frequently asked questions

Can the server operator read my secret?

No. The server only ever stores the encrypted ciphertext. The decryption key exists only in the URL fragment, which browsers never include in HTTP requests and which is not logged anywhere on the server. Without the key, the ciphertext is indistinguishable from random noise.

What if someone intercepts the link?

If an attacker intercepts the full link (including the #fragment) before the intended recipient opens it, they could view it first. This is the fundamental trade-off of any link-based secret sharing system. Use a trusted channel to share the link — and tell the recipient to let you know once they've opened it so you can detect if it was already consumed.

What happens if I refresh the view page?

The secret is deleted the moment it is first fetched. A page refresh will return "Secret not found or already viewed". This is by design — the view page also removes the key from the browser's URL bar immediately after decrypting, so it is not left in your history.

Is the secret really deleted, or just hidden?

The database row is deleted with a SQL DELETE before the response is sent. SQLite writes are synchronous (using better-sqlite3), so the delete is committed to disk before the payload is returned. There is no soft-delete, archive, or log of the content.

Why AES-256-GCM specifically?

GCM (Galois/Counter Mode) provides authenticated encryption — it simultaneously encrypts and produces an authentication tag. Any modification to the ciphertext, IV, or associated data causes decryption to fail with a hard error. This protects against both passive eavesdropping and active tampering. It is the current NIST recommendation for symmetric authenticated encryption and is natively supported in every modern browser via the Web Crypto API.

Does this work without JavaScript?

No. Encryption and decryption run entirely in the browser using JavaScript. This is intentional — it is what keeps the key off the server. A no-JS fallback would require the server to handle keys, which would defeat the zero-knowledge design.

Approved by Cynaps