
End-to-end encryption means the server cannot read the plaintext. A ‘forgot password’ mail that restores notes means it can.
If your product emails a recovery that rebuilds the user’s vault, you are not E2E. You are encryption at rest with a key you hold. That can still be the right product. It is a different claim.
The usual mistake is marketing E2E and then adding a support tool that decrypts on the server.
This page is how to tell those designs apart, and what you owe the user in the privacy copy when you hold the key.
A “forgot password” mail that restores a user’s notes means the server can read those notes. That product is not end-to-end encrypted, whatever the pricing page says. The server that can reset you is an end.
The control is a sentence you can defend: which process can obtain plaintext, and what you give up so that the operator cannot.
True E2EE and a CRUD reset share a store and almost nothing else. On one side the server only holds dek_wrap. On the other a password reset, a support unwrap, and a search index all prove the operator can read.
SecureCoding
What E2EE actually requires
Ends are the parties who may see plaintext. Everyone else, including the host that stores ciphertext, must be unable to obtain it. Not “we promise not to look.” Unable. A lawful request, a stolen replica, a support admin, a debug log: still ciphertext.
That definition is older than Signal. RFC 9420, MLS, published in July 2023, is the IETF group-messaging form. The Signal protocol is the pairwise form most people mean. Both assume a client that holds a long-term identity key the service cannot mint. Both assume that a lost device is a lost inbox unless the user has another device that already shares the key. Recovery that does not involve a second end is operator access.
TLS is not this. TLS ends at your load balancer, or at the app process. The TLS page is the hop. After the hop, your server has the body. Calling HTTPS “E2EE” is the most common misuse on a marketing site. HTTPS is required. It is a different lock.
App-level AES-GCM that the API process unwraps with FIELD_KEK is also not this. That design is the sibling page on database encryption. It hides a dump from a replica you did not mean to share. The API still reads the PAN to render a page. The operator can still read it.
| Claim on the site | Who can read | Honest name |
|---|---|---|
| HTTPS only | Your app, your logs, your DB | In transit |
| TDE or volume crypto | Any process with the keyring | Media lock |
| Server unwraps a field | Your API and anyone who can call it | Column crypto |
| Client holds the only KEK | That client, and any JS you ship to it | E2EE, with a caveat |
A password reset is the tell
Walk your own forgot-password path. If the user receives a link, picks a new password, and the notes reappear, the notes were wrapped under a secret you can replace. You replaced it. You could have unwrapped them without the user.
The honest designs are few:
- No recovery. Lose the password and the passphrase-derived KEK, lose the ciphertext. Say that on the signup screen. Charge less support time, or charge more for a hardware-key backup the user holds.
- A second end the user already owns. Another device, a printed recovery key, a hardware token. The server stores a wrapped
dekit cannot unwrap. The second end can. - Operator recovery. You keep a KEK or a break-glass wrap. Call it account recovery. Do not call it E2EE.
A support tool that can “unlock this tenant for the customer” is the third design, even if the happy path uses a passphrase wrap. The existence of the tool is the access.
CRUD features that force the server to read
A notes app, a CRM, a helpdesk, an invoicing SaaS: the product is the server doing work on the user’s objects. Each of these needs plaintext on a machine you run.
- Search.
LIKE '%invoice%'and a full-text index need tokens the engine can see. Blind indexes help exact match. They do not give you ranking across a tenant’s corpus unless you invent a searchable scheme you will not finish this quarter. - Email and webhooks. A reminder, a digest, a Slack post, a PDF invoice. Something on your side renders the body.
- Server-rendered HTML. The first paint of
/notes/1284is your process reading the row. A SPA that decrypts in the tab can avoid that paint. The API that listed the titles still had to decide what to list. - Admin and support. Impersonation, GDPR export, abuse review, legal hold. Someone you employ will need a path. If that path exists, the operator is an end.
- Analytics and ML. Token counts, “similar tickets,” spam scores. Those jobs read content or they do not run.
- Backups you can restore for a customer. A backup of ciphertext plus a KEK you hold is a backup you can read.
If you need four of those, you do not have E2EE. You have a product. Protect it with TLS, column crypto for the few fields a dump would hurt, a session that is not a guessable id, and access control on every object. The secure coding checklist is the rest.
Messaging is the shape that fits. Two devices, a sealed payload, a server that stores blobs and fan-out. Group chat is MLS or a sender-key tree you are prepared to operate. A multi-tenant CRM is not that shape. Stop stretching the word over it.
The browser is a bad endpoint
palata’s July 2026 comment also names the web mail problem. You open the origin. The origin sends JavaScript. That script is the client. Tomorrow’s script can be a different client. A native app with a signed update is still a client you can swap, but the swap is a release you can pin and audit. A browser tab is a release on every GET.
WebCrypto is real. window.crypto.subtle will AES-GCM and will derive a key with PBKDF2. The API is not the trust problem. The trust problem is who authors the script that calls it.
// Browser: wrap a dek with a passphrase-derived kek.
// This is crypto. It is not a pinned client.
async function wrapDek(passphrase, dek) {
const enc = new TextEncoder();
const salt = crypto.getRandomValues(new Uint8Array(16));
const base = await crypto.subtle.importKey(
"raw", enc.encode(passphrase), "PBKDF2", false, ["deriveKey"],
);
const kek = await crypto.subtle.deriveKey(
{ name: "PBKDF2", salt, iterations: 600000, hash: "SHA-256" },
base,
{ name: "AES-KW", length: 256 },
false,
["wrapKey", "unwrapKey"],
);
const dekKey = await crypto.subtle.importKey(
"raw", dek, "AES-GCM", true, ["encrypt", "decrypt"],
);
const dek_wrap = await crypto.subtle.wrapKey("raw", dekKey, kek, "AES-KW");
return { salt, dek_wrap };
}
Identifiers stay dek, kek, dek_wrap, passphrase, and salt. 600000 PBKDF2 iterations is the OWASP 2023-era ballpark for SHA-256 on a password. Prefer Argon2id in a native client. WebCrypto still lacks a first-class Argon2 in every browser I could name on 22 August 2026. If you stay on PBKDF2, keep the iteration count in the stored blob so you can raise it later.
A service worker that pins a hash of the decrypt bundle is a research pattern, not a product I will tell you is done. Subresource Integrity pins a CDN script to a hash the HTML named. The HTML still comes from you. If you want E2EE a stranger can believe, ship a native client with reproducible builds, or say the user is trusting this origin today.
What you can ship instead
Pick a sentence and implement that sentence.
Honest SaaS, no E2EE claim. TLS everywhere. Session cookie with a Host prefix. Object checks on every read. Encrypt the few columns a dump would hurt, with a KEK in a vault the API is allowed to use. Say “we can read your data and we limit who does.” That is most products. It is enough if you mean it.
// API process. This is column crypto. Do not label the route e2ee.
const { encryptPan, decryptPan, loadKek } = require("./panCrypto");
app.get("/cards/:id", async (req, res) => {
const orgId = req.actor.orgId;
const [rows] = await pool.execute(
"SELECT id, last4, pan_ct, pan_iv, pan_tag, dek_wrap FROM cards WHERE id = ? AND org_id = ?",
[req.params.id, orgId],
);
if (!rows[0]) return res.status(404).end();
const pan = decryptPan(rows[0], loadKek());
res.json({ id: rows[0].id, last4: rows[0].last4, pan });
});
Sealed field, server-blind. The browser or the native app encrypts a blob the API never unwraps. You give up search, email previews, and server-side render of that blob. Titles and timestamps stay in the clear so the list view works. Password reset wipes the blob or leaves it sealed. Write that on the settings page.
function asB64(value, name) {
if (typeof value !== "string" || value.length < 16) {
throw new Error(name);
}
return Buffer.from(value, "base64");
}
app.post("/notes", async (req, res) => {
let note_ct, note_iv, note_tag, dek_wrap, salt;
try {
note_ct = asB64(req.body.note_ct, "note_ct");
note_iv = asB64(req.body.note_iv, "note_iv");
note_tag = asB64(req.body.note_tag, "note_tag");
dek_wrap = asB64(req.body.dek_wrap, "dek_wrap");
salt = asB64(req.body.salt, "salt");
} catch (err) {
res.status(400).send("sealed fields required");
return;
}
await pool.execute(
`INSERT INTO notes (org_id, user_id, note_ct, note_iv, note_tag, dek_wrap, salt)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[req.actor.orgId, req.actor.id, note_ct, note_iv, note_tag, dek_wrap, salt],
);
res.status(201).end();
});
The API above must not grow a SUPPORT_KEK unwrap in a later sprint. Add a CI grep for decryptPan and for createDecipheriv in the notes service. If a decrypt appears, the claim died.
Native messenger. Do not start from a CRUD schema. Start from a sealed payload, device identities, and a delivery service. That is a different product than /api/tickets.
Sessions stay ordinary. E2EE does not replace a cookie or a bearer token that proves who may upload a sealed blob. A sealed blob posted for the wrong org_id is still an object bug. Do not skip the session page because the bytes are opaque.
Prove the claim, or drop the word
You are not breaking someone else’s messenger. You are proving your own product matches the sentence on the pricing page.
- Write the sentence. “The operator cannot obtain note plaintext, including after password reset and including from backups.”
- Reset a password on a staging tenant you own. If the notes reappear without a recovery key the user pasted, the sentence is false.
- Grep the API for
createDecipheriv,decryptPan,subtle.decryptin server code, andSUPPORT_KEK. - Restore last night’s backup to a throwaway instance. If a process you run can print a note, the sentence is false.
rg -n "createDecipheriv|decryptPan|SUPPORT_KEK|unwrapDek\\(" \
--glob '!node_modules' --glob '!public/**'
# A notes service that claims E2EE must print nothing here.
# staging tenant you own. After a password reset, this must stay empty
# unless the user supplied a recovery key in the same session.
psql "$STAGING_URL" -c "SELECT id, left(encode(note_ct,'hex'),16) FROM notes LIMIT 5"
# Expect ciphertext. Then try your support admin tool.
# Expect that tool to have no decrypt action.
If step 2 or step 4 fails, change the pricing page this week. “Encrypted at rest” and “TLS 1.3” are still true and still worth saying. They are not E2EE. A JWT in localStorage is not E2EE either. That token is a session. See the JWT guide if the header is the thing you actually meant.
Questions we keep getting
Can I add E2EE to an existing ticket tracker?
Not without deleting features. Search, macros, SLA mail, and supervisor review all read the ticket. You can seal attachments and leave the thread readable. Call that sealed attachments. Do not retitle the product.
Does WebCrypto in the tab count?
The math can. The client cannot be pinned the way a signed APK can. Tell users they are trusting this origin’s JavaScript today. If that sentence feels too weak to print, you already know the answer.
Is a zero-knowledge backup E2EE?
If the only wrap is a passphrase or a key the user holds, and you have no second wrap, yes for that backup. The live app that sits next to it is a separate claim. Prove both.



