Why this post
In part 1 I described how a SecretBolt link works today. The short version: secrets are encrypted at rest with AES-256-GCM, but the server decrypts them when someone opens the link. That is a trust-the-operator design, and I said so.
In the comments, a reader suggested the obvious next step: put the key in the URL fragment and decrypt in the browser, so the server never holds anything it can read. They also gave a piece of advice I want to build around: keep the decrypt page tiny and static, with no third-party scripts, so people can actually check what runs.
This post is the design, written before the code. I'd like you to poke holes in it. Anything you find now is cheaper to fix than after it ships.
What the server sees, today and with E2E
| Today | E2E mode | |
|---|---|---|
| Secret content | Encrypted at rest, decrypted by the server on view | Ciphertext only; the server never has the key |
| Encryption key | Rails Active Record Encryption keys on the server | 256-bit key generated in the sender's browser |
| Where the key travels | Never leaves the server | Only in the link fragment (#...), which browsers don't send to servers |
| A database or backup leak exposes | Plaintext of live secrets, if the encryption keys leaked too. Destroyed secrets are already erased | Ciphertext of live secrets only |
| Passphrase | bcrypt hash, checked by the server | Mixed into the key in the browser; the server never sees it |
| Event log | Created, viewed, expired, destroyed; IP and user agent on views | Same; content, key and passphrase are never logged |
The fragment trick is not new: PrivateBin, Bitwarden Send and Yopass all keep the key after the #. What I want to get right is the part around it: who can spend a view, what the passphrase protects, and the page that does the decryption.
The flow
In v1, E2E is a checkbox on both web forms: the no-account form on the home page and the form in the dashboard. Here is what happens when it is ticked.
- Generate a key. The browser generates a random 256-bit master key with WebCrypto. If the sender sets a passphrase, it is stretched with PBKDF2-SHA256 (600,000 iterations, random 128-bit salt) and mixed in.
-
Derive two values. HKDF turns the master key into two independent values:
enc_keyfor encryption andaccess_tokenfor the server. Knowingaccess_tokentells you nothing aboutenc_key. - Encrypt. AES-256-GCM with a random 96-bit IV. The format version goes in as additional authenticated data, so the version label can't be swapped without breaking decryption.
-
Upload. The server receives the ciphertext, the IV, a hash of
access_tokenand the usual settings (views, expiry). It never sees the master key,enc_keyor the passphrase. -
Build the link. The sender gets
https://secretbolt.io/s/<token>#v1.<key>, or#v1.p.<salt>.<key>when there is a passphrase. The token finds the record; everything after#stays in the browser. Thev1prefix lets the format change later without breaking old links. Thepflag tells the static page to ask for a passphrase, and the salt travels with it: a salt isn't secret, and this way the page needs nothing from the server before Reveal. -
Open the link. For an E2E secret,
/s/<token>serves a prebuilt static HTML file, outside the Rails layout and importmap, so the page runs with no inline scripts at all. It removes the key from the address bar withhistory.replaceState. -
Reveal. Nothing is fetched until the recipient presses Reveal, and enters the passphrase if there is one. The browser derives
access_tokenand sends it in a POST. The server hands out the ciphertext only if the hash matches, and only then counts the view. Link-preview bots never get that far. - Decrypt. The browser decrypts and shows the secret. The server erases the ciphertext under the same rules as today.
What a wrong key or passphrase does
If a chat app cut off the end of the link, or the passphrase is wrong, the derived access_token doesn't match. The server refuses, and the view is not spent: the recipient can ask for the full link and try again. A token that leaks on its own, from a log for example, can't open the secret or burn it. Wrong attempts will be rate-limited per secret and per IP. After a wrong passphrase, keep the tab open to retry: the key is no longer in the address bar, so a reload needs the link again.
Each secret has its own key, so a ciphertext moved into another record simply fails to decrypt.
The passphrase
Because the passphrase is part of the key, a compromised server learns nothing from it, and there is no bcrypt hash to attack. The price is that a forgotten passphrase can't be recovered by anyone, including us.
I chose PBKDF2 over Argon2 on purpose. Argon2 isn't in WebCrypto and would need a WASM bundle, which goes against a tiny decrypt page. PBKDF2-SHA256 is built into every browser, and 600,000 iterations follows the current OWASP guidance.
Size limits
The server no longer sees the text, so limits apply to the ciphertext. It is about a third larger than the text (base64 plus the GCM tag), and the 10 KB limit for no-account secrets will be counted on the ciphertext. In practice that means about 7.5 KB of text.
The decrypt page: tiny, static, verifiable
With E2E, the code that runs in your browser is the security boundary. So the page that runs it should be small enough to read in a few minutes. Encryption happens on the home page and in the dashboard, which stay regular Rails pages: the code that touches the key there is the same kind of static script file, loaded on its own, but the page around it is rendered by Rails. Only the decrypt page is fully static.
- Static. One HTML file and one JavaScript file, served as they are, outside the Rails layout and importmap. No templates, no framework, no build step that changes the output between deploys.
- No third-party code. No analytics, fonts, CDNs or chat widgets on the create and decrypt pages.
-
Strict CSP.
script-src 'self', no inline scripts,connect-src 'self',frame-ancestors 'none'. -
Referrer-Policy: no-referrer, as a second layer on top of the fragment never being sent. - Published source and hash. The source of the decrypt page and of the encryption script will be public, and the SHA-256 of the served script will be listed in the docs. You can compare what your browser received with what you reviewed.
A note on Subresource Integrity: the page will use it, but it buys little here. The HTML and the script come from the same server, so whoever can swap the script can swap the integrity attribute too. SRI here mainly pins the HTML to one exact script version; it does not defend against our own server.
The published hash doesn't make the page trustless either. It makes a quiet change noticeable, which is about as far as a web page can go.
What this does not protect against
I'd rather list these up front than have someone find them in the comments.
- A malicious or compromised server. If the server sends different JavaScript, it can read the key and the passphrase. The published hash helps you notice, but most people won't check every time. This is the honest limit of any browser-based E2E.
- Browser extensions with access to the page can read the secret after decryption.
- Wherever the link was pasted. The key lives in the full link, so it sits in the chat or email you sent it through, and possibly in the sender's browser history. One-time viewing limits the damage: after the first view there is no ciphertext left to decrypt.
-
The recipient's browser history.
replaceStateruns only after the page has loaded, so the full link with the key may already be in the recipient's history, and synced to their browser account. One-time viewing limits this too. - Whoever opens the link first. If someone intercepts the link, they get the secret and the real recipient sees "already viewed". A passphrase sent over a different channel closes most of that gap.
- Link scanners that click. Email security scanners and some preview bots open links in a real browser, fragment included. They see only the static page, because nothing is fetched before Reveal. A scanner that also presses buttons could burn the view; a passphrase sent separately stops that.
Abuse handling never relied on reading content, so it doesn't change: rate limits, size limits and the Report abuse link work the same for E2E secrets.
What stays the same in v1
E2E is opt-in on both web forms. The API, the CLI examples and the /export endpoint keep working exactly as described in part 1. An E2E secret requested through the API returns a clear error instead of ciphertext. Doing the decryption in a shell without leaking the key is a separate design problem, and I'd rather solve it properly later.
Open questions
These are the parts I'm least sure about. Replies in the comments are very welcome.
- PBKDF2 cost on phones. 600,000 iterations takes a noticeable moment on older phones. Is that fine for a one-off reveal, or is a lower count with stricter server-side rate limits the better trade?
- IP and user agent in E2E mode. Today the event log keeps the IP and user agent of each view, for abuse handling, and deletes them with the record, 30 days after the secret is destroyed. Should E2E secrets log less, or is the abuse case worth keeping it?
-
Hash verification. Is a published SHA-256 plus public source enough? A saved local copy of the page would be stronger, but opened from
file://its requests run into CORS andconnect-src, so it isn't a free win.
You can try the current version without an account at secretbolt.io. If you'd like to hear when E2E ships, follow this series; part 3 will cover what changed after your feedback.
Top comments (2)
Solid design, and writing the honest limits down is the best part. One case to add next to "whoever opens the link first": email security scanners and some chat link-preview bots load links in a real browser. Since they get the full URL, fragment included, keeping the ciphertext fetch strictly behind the Reveal click (never on page load) is what stops a scanner from burning the one-time view.
Thanks, good case to spell out. That's exactly why step 7 has the fetch behind the Reveal click and never on page load: a scanner that opens the link in a real browser gets the static page and nothing else.
The remaining edge is a scanner that also clicks buttons. Then it has the full link and could burn the view. A passphrase covers that, since it never travels with the link. I've added this to the limits section.