Security · In Depth

The complete cryptographic lifecycle

A precise walkthrough of what happens to your data at every step — with the primitives, parameters and trust boundaries spelled out. Each step opens with a plain-English summary, so you can read at whatever depth you like.

Step 01 · Encrypt

Your secret is sealed on your device

In plain terms: the scrambling happens in your browser, before anything is uploaded. What we receive is already unreadable.

When you press Encrypt, the browser draws a fresh 256-bit content key from the WebCrypto CSPRNG and encrypts your payload with AES-256-GCM. GCM is authenticated: every chunk carries a 128-bit tag, so any later tampering is detected rather than silently decrypted.

Text and files are handled identically — both become one opaque binary blob, padded to a standard size so a short password and a small file look alike. The plaintext never touches the network and never leaves the tab's memory.

CipherAES-256-GCM, 64 KiB chunks
Content key256-bit, fresh per secret
Nonce7-byte random prefix + counter
Auth tag128-bit per chunk
PaddingPadmé, 64 KiB minimum

Step 02 · Key custody

The key hides in the link, not on our servers

In plain terms: the part of the link after the # holds the key — and browsers never send that part to any server.

Your link carries a random 256-bit seed in the URL fragment — everything after the #. By the URL specification the fragment is stripped by the browser and never included in the request sent to our servers. From that seed the browser derives, with HKDF, a lookup value to find the secret and the key that unwraps the content key. The server only ever sees a hash of the lookup value.

The recipient's browser reads the seed from the fragment and does the same derivation locally. This is what "zero-knowledge" means here: possession of the ciphertext alone is useless without the link.

Key locationURL fragment #
Sent to server?never
Seed32 random bytes, base64url
DerivationHKDF-SHA-256
Server storesSHA-256(lookup) only

Step 03 · Optional passphrase

A second lock only humans hold

In plain terms: add a passphrase and even someone with the full link can't open the secret without it.

If you set a passphrase, it's stretched with Argon2id — a memory-hard key-derivation function — and combined with the seed into the key that wraps the content key. The passphrase itself is never transmitted or stored; only you and the recipient know it. Wrong guesses are retried in the recipient's browser against data it already holds, so the server can't count them.

Memory-hardness makes large-scale offline guessing expensive even on GPUs and ASICs. Share the passphrase over a different channel than the link and the two factors never meet in one place. The Argon2 module is fetched on every create and reveal, so the network can't tell which secrets use one.

KDFArgon2id
Salt128-bit random
WrappingAES-256-GCM, 96-bit IV

Step 04 · Transit & storage

Only ciphertext leaves your device

In plain terms: we store a locked box and an expiry time. Nothing else — no plaintext, no key, no record of you.

The blob is uploaded over TLS and stored at rest as ciphertext beside hashed credentials, its lifetime and a rounded expiry time. We deliberately keep no sender identity, recipient, IP log, creation time or access history. There is no account system to tie a secret to a person. Rate limiting works on an HMAC of the address under a salt that rotates, so nothing before a rotation can be linked to anything after.

Because the stored object is meaningless without the seed in the fragment, a server compromise or a subpoena yields opaque bytes and hashes that can't be turned back into anything.

In transitTLS
At restciphertext + hashed keys + expiry
Logsnone per secret

Step 05 · Retrieval & destruction

First read wins — then it's erased

In plain terms: opening a one-time link hands over the box once and deletes the server's copy. Or destroy it yourself, anytime.

Nothing is fetched when the reveal page loads: the request goes out only on a real click or key press, so link scanners and chat previews can't consume a secret. For a one-time secret the server claims the row in a single conditional update — exactly one request wins — and the secret counts as consumed from that moment. The same browser can re-download for 15 minutes if the connection drops; nobody else can, and an unconfirmed claim is destroyed, never reverted. Timed secrets are a plain read until they expire.

Every secret also has a private burn link. Opening it destroys nothing by itself; confirming erases the ciphertext immediately — before any recipient can read it.

One-timeatomic claim, then destroyed
Re-download window15 min, same client only
Expiry15 min – 14 days
Owner controlburn link

Threat model

What shhnote can and cannot see

The honest version. Here's exactly where the trust boundary sits.

Never leaves your device

What we can't see

Your plaintext · the content key and the seed · your passphrase · whether a passphrase was set · who created or opened a secret · when it was created · IP-level access logs. None of it is transmitted or retained.

Stored on our servers

What we do hold

An encrypted blob we can't open · its padded size class · one-way hashes of the values your browser uses to find or burn it · whether it is one-time or timed · a rounded expiry time. All of it useless without the seed in your link.

Worth knowing

The honest caveats

Anyone with the full link (and passphrase, if set) can read a one-time secret — treat the link as the secret. After a secret is shown, the page overwrites what it can when you leave, but JavaScript can't guarantee it; close the tab when you're done. A browser driven by automation that injects real input can open a one-time secret the way a person would — the recipient then finds nothing, which is the honest outcome. Deleting a secret removes the record; encrypted leftovers may linger in journals, free disk space or backups until overwritten, as ciphertext without a key. Large secrets stored outside the database carry the time the file was written. And as with any web app, you trust the code served to your browser — that's why shhnote is open source and self-hostable.

Cryptographic specification

Every primitive, in one place

Encryption

AES-256-GCM

Authenticated symmetric encryption in 64 KiB chunks. A 256-bit key plus an integrity tag per chunk, so tampering or truncation is detected — not just left unreadable.

Key derivation

HKDF · Argon2id

Keys and identifiers come from the seed through HKDF-SHA-256. Optional passphrases are stretched with a memory-hard KDF, making offline brute-force slow and costly even on dedicated hardware.

Randomness

WebCrypto CSPRNG

Seeds, keys, salts and nonces come from the browser's cryptographically secure generator. Never Math.random(), never predictable.

Key custody

URL fragment #

The seed rides after the # in your link. Browsers never transmit the fragment, so it never reaches our servers.

In transit

TLS

Only ciphertext ever leaves your device, carried over modern TLS. What we receive is an opaque blob we can't open.

At rest

Ciphertext + hashes

We persist an encrypted blob, one-way hashes of its credentials and a rounded expiry — no plaintext, no keys, no sender or recipient metadata.

Technical FAQ

For the security-minded

How is the one-time guarantee enforced against races?

The reveal runs a single conditional update that moves the row from "active" to "claimed". Two simultaneous requests can't both succeed; the second observes an already-claimed record and gets the same "no secret found" response as anyone else. The claim is never reverted: if the download is lost, the holder of the claim token re-downloads within the window, and otherwise the secret is destroyed unread.

Why the URL fragment instead of a query parameter?

Query parameters are sent to the server and land in access logs, proxies and analytics. The fragment (after #) is defined by the URL spec to stay client-side — the browser never puts it in the HTTP request. That property is the whole basis of the zero-knowledge design. After a reveal, the page also removes the fragment from the address bar and history.

What stops link scanners from burning one-time secrets early?

The view page is the same static document for every secret and fetches nothing on load. The request is sent only on a trusted click or key press — a scripted click() or form submission doesn't count. Automated scanners (email security, chat unfurlers, antivirus) only ever load that static shell.

Can I verify or self-host this?

Yes. The full source is public under AGPLv3, the protocol is written down in a specification with conformance vectors that any implementation can check itself against, and there's a self-hosting guide. You can audit the client-side crypto and run your own instance so the trust boundary is entirely yours.

Is anything logged for abuse prevention?

We keep no per-secret access logs or IP history. Rate limiting uses a keyed hash of the address under a salt that rotates and clears every counter with it. Abuse reports are counted per day; there is no record of which secret was reported.

Tips for sharing safely

  • Send the passphrase a different way from the link, for example the link by email and the passphrase by phone.
  • Pick the shortest expiry that works for you.
  • Use a one-time secret for passwords and other information that should be read exactly once.

Reporting a vulnerability

If you think you’ve found a security problem with this site, please email [email protected] and tell us privately before telling anyone else. Include what you found and how to reproduce it.

We will:

  • reply within 5 working days;
  • keep you updated while we fix it;
  • credit you when we publish the fix, if you’d like.

Please don’t access other people’s data, disrupt the service, or run automated scans that put heavy load on it while testing.

Vulnerabilities in the shhnote software itself: see the project’s security policy.

Create a secret View source on GitHub

Try it — it takes ten seconds

Write something secret, get your one-time link, and see it disappear.

Create a secret