Engineering · encryption
Encrypting User Data Without Holding the Key, Part 1: Two Secrets From a Password, One Key That Never Changes
by Sylvain Artois on Aug 30, 2026
- #encryption
- #key-management
- #argon2
- #hkdf
- #webcrypto
- #envelope-encryption
- #javascript
In February 2026, someone read the bank details of about 1.2 million people after taking over a French civil servant’s login to a national account registry. Four months later the same administration cut off a second intrusion — and the public only heard about it in mid-August, when the attacker announced it. Officials said close to the same thing both times: no software broke, no encryption was cracked. A real account did exactly what a real account is allowed to do. I want to walk through what defending against that actually looks like — with a fictional app and real code, instead of staying in the abstract the whole way through.
Comptio, a bookkeeping app that doesn’t exist
Let’s invent something concrete: Comptio, a small SaaS for freelancers and tiny businesses. Nothing about it is real — I made it up so I’d have real code to show instead of talking in the abstract.
- Every month, a user like Marie — runs a one-person consulting business — uploads her bank statement export, an
.xlsxfile her bank gives her. - Comptio reads the transactions, categorizes them, and helps her reconcile her books.
- Behind that one feature sits: her income, her clients’ names, three years of transaction history, her address.
That is, roughly, the shape of what leaked from the French tax administration over the summer. Not documents from a vault — rows of ordinary financial and identity data, read by a login that had no business reading that many of them.
The easy default: the server holds the key
Most apps that “encrypt everything” do it the same way, called envelope encryption.
One word first, because the whole article rests on it. To wrap a key is to encrypt it with another key. That’s all. A wrapped key is a small blob you can store in a database column, in a backup, in a log if you were careless — useless to anyone who doesn’t hold the key above it. The primitive browsers give you for this is AES-KW, specified in RFC 3394.
The usual arrangement:
- Each file gets its own random encryption key.
- That key is itself wrapped by one master key.
- The master key lives in a secret manager or an environment variable, on the server.
This is solid, ordinary engineering. It stops:
- a stolen laptop or disk,
- a leaked backup,
- a misconfigured storage bucket.
It does not stop the case where the server itself is asked, correctly, to decrypt something — by a login that has the right to ask. If Comptio’s server can decrypt Marie’s statement on demand, then anyone who can pass as Marie, or anyone on Comptio’s own team with a normal, unrevoked login, can decrypt it too. Same shape of failure as the tax administration breach: the lock was never the problem.
A different promise: Comptio never holds the key
The other model — the one behind Bitwarden, 1Password, and Proton — is user-held key wrapping. The server still stores encrypted files. The key that opens them is generated on the person’s own device and never sent anywhere in a usable form.
Two sentences worth keeping side by side, because only one of them is honest:
Allowed: “We can’t read your data while you’re not here. Every time we do, it’s because of something you did, and it’s logged.”
Not allowed, unless it’s literally true: “We can’t read your data.”
Comptio needs to read Marie’s statement once, on upload, to pull out the transactions. That single deliberate moment is exactly why the second sentence would be a lie for an app like this — Proton itself draws this line between full end-to-end encryption and the weaker, still-useful property of the server having no standing access. Part 2 gets specific about what that moment actually costs.
Three primitives, three jobs
Getting from “a password Marie typed” to “a key Comptio’s server never sees” leans on three well-worn pieces of cryptography. None are exotic. All three have a Wikipedia page for a reason.
1. A real random number generator
Every key in this system — and there will be several — has to come from a CSPRNG, a cryptographically secure random generator. In a browser:
const masterKeyBytes = crypto.getRandomValues(new Uint8Array(32));
- Use
crypto.getRandomValues. - Never
Math.random()— it’s built for shuffling a card game, not for secrets. Its output is predictable enough that seeing a few values can let someone guess the rest.
2. A slow way to turn a password into a secret
A password is not very random — people reuse them, keep them short, pick real words. Hash it once, fast, and anyone who ever gets that hash (a leaked database, a careless log) can try billions of guesses a second.
Argon2id exists to make guessing expensive. It’s memory-hard: computing it once takes real time and real RAM, on purpose. It won the Password Hashing Competition, and it’s what the OWASP Password Storage Cheat Sheet and current NIST guidance both recommend today. The exact parameters are defined in RFC 9106.
In a browser, that means WebAssembly — there’s no native Argon2id in the platform. hash-wasm is a real, widely used option:
import { argon2id } from "hash-wasm";
const root = await argon2id({
password: typedPassword,
salt: localSalt, // see the aside below — there are two salts here
parallelism: 1,
iterations: 3,
memorySize: 65536, // 64 MB
hashLength: 32,
outputType: "binary",
});
Those three numbers are a choice, not a copy-paste. 64 MB with three passes is RFC 9106’s second recommended profile — the one a browser can actually run without the tab going grey for four seconds. And parallelism: 1 is not a weakening, it’s an admission: WebAssembly in a browser has no threads by default, so asking for the RFC’s p=4 would buy nothing but a slower single-threaded run. About a second on Marie’s laptop, which is the number to tune against.
3. One secret, many keys
Argon2id gives Comptio one strong secret. It needs two: something to prove Marie’s identity to the server, and something that never leaves her browser to unlock her data. Running Argon2id twice would double the cost for nothing — and reusing one secret for two unrelated jobs is the kind of shortcut that turns into a vulnerability years later.
HKDF (RFC 5869) fixes this cheaply: feed it one secret and a short label, get back a new key that’s computationally unrelated to any other key derived from the same secret with a different label.
const rootMaterial = await crypto.subtle.importKey(
"raw", root, "HKDF", false, ["deriveBits"]
);
const kekBits = await crypto.subtle.deriveBits(
{
name: "HKDF",
hash: "SHA-256",
salt: kekSalt, // the account's own salt
info: new TextEncoder().encode("comptio:kek:v1"),
},
rootMaterial,
256
);
const kek = await crypto.subtle.importKey(
"raw", kekBits, "AES-KW", false, ["wrapKey", "unwrapKey"]
);
Swap "comptio:kek:v1" for "comptio:verifier:v1" and the same root secret produces a second, unrelated value — the one Comptio’s server is actually allowed to see.
Assembling Marie’s sign-up
Marie's email address the password she typed
│ │
│ HKDF, label "salt" │
▼ │
stretching salt ──────────────┐ │
▼ ▼
Argon2id — slow, once, about a second
│
root secret
│
┌──────────────────────┴──────────────────────┐
│ HKDF, "verifier" │ HKDF, "kek"
▼ ▼ (+ kekSalt)
sent to the server, to log in stays in the browser,
unlocks nothing yet
The verifier proves Marie knows her password without giving the server anything it could use to derive the KEK. A full dump of every verifier Comptio ever stored is useless for decrypting anyone’s statements — only useful as something to try guessing offline, at Argon2id’s price per attempt.
If Comptio’s login is built on Better Auth — a popular open-source auth library — this fits its config cleanly, because it never needs to know the “password” it receives isn’t one:
import { betterAuth } from "better-auth";
import { hash, verify } from "@node-rs/argon2";
export const auth = betterAuth({
emailAndPassword: {
enabled: true,
password: {
// Comptio's server never sees what Marie typed — only the verifier her
// browser derived, base64url-encoded, because this seam carries strings
// and the verifier is 32 raw bytes.
//
// Cheap parameters on purpose: the input is already 256 bits of
// uniform entropy, not a password. There is no dictionary to make
// expensive. The server hashes it so that a database dump isn't a
// pile of ready-to-use login tokens — nothing more.
hash: (verifier) => hash(verifier, { memoryCost: 19456, timeCost: 2 }),
verify: ({ password, hash: stored }) => verify(stored, password),
},
},
});
An aside on salts, because there are two of them
Skip this on a first read if you like, but it’s where I got it wrong first, and the mistake is instructive.
The stretching salt is the one Argon2id takes. The tempting move is to have the server hand it out, keyed on the email address, before anything else happens. I built that version. It fails twice over.
The obvious failure: an endpoint that answers “here’s Bob’s salt” before Bob has logged in is a free way to check whether bob@example.com has an account. And if it can be talked into serving weaker parameters, it can quietly downgrade every future login for that account.
The less obvious failure is the one that actually killed the design. To avoid being an enumeration oracle, that endpoint has to answer identically to everyone — the same shape for an account that exists and one that doesn’t — which in practice means answering something that never changes. And a secret that never changes is a password that cannot be changed.
So Comptio derives this salt locally instead, from the normalised email address, through HKDF with a label and constants shipped in its own JavaScript bundle. No network call happens before Marie has typed anything, and there is no door to turn into an oracle because there is no door.
The account salt (kekSalt above) is a different object doing a different job. Thirty-two random bytes drawn at signup, stored server-side, and served on an authenticated route — after the verifier has been checked and a session exists, so it isn’t a door anyone can knock on.
Strictly, HKDF doesn’t need it: RFC 5869 §3.1 explicitly allows an empty salt when the input keying material is already high-entropy, which Argon2id’s output is. It’s there so the KEK has a moving part that isn’t Marie’s password — something Comptio can rotate on its own, without the person changing anything they know.
Which means the sign-in order matters: the expensive part happens before any network call, and the account-specific part happens behind the session. The full path is written out at the end of this article, once there is something for the KEK to unlock.
The version I built first
Marie’s browser now holds a KEK, and it unlocks nothing yet. The tempting move is to use it right away — wrap every statement’s key straight under it, ship it, move on to the next feature:
// DON'T — this is the mistake this article is about.
const wrappedFileKey = await crypto.subtle.wrapKey(
"raw", fileKey, kek, "AES-KW"
);
Every statement Marie uploads gets a key, wrapped straight under her password-derived KEK. It passed every test I wrote. Then I sat down to write the password-change screen and did the math.
What a password change costs, this way:
- Derive a brand-new KEK from the new password (a fresh Argon2id run).
- Unwrap every key Marie owns with the old KEK — every statement she has ever uploaded, and every separately-keyed field of her profile besides.
- Re-wrap each one under the new KEK.
- Do all of this while Marie is present, with both her old and new password’s KEK in memory at once — the old one won’t work again once this is done.
- If her laptop dies halfway through, some of her data is wrapped under the new key and some under the old one. Good luck.
Notice what the size of that job depends on: not on the password, not on anything about the change itself, but on how much Marie has stored. A new account pays nothing. Someone who has been uploading for six years pays for six years, and pays again next time. The cost grows with exactly the thing you want to grow.
And it isn’t a rare event to design around. People change passwords after a breach notice, after reusing one somewhere careless, or because a password manager nagged them. A design where that costs a migration through a person’s entire history, every time, is not a rough edge. It’s a wrong turn.
The fix: one more key in the middle
The rule that fixes it, once you’ve seen it, is hard to unsee: never let something that changes often protect something expensive to re-protect.
- Master key — one random key, generated once at signup, via CSPRNG. Never derived from a password. Never changes.
- KEK — derived from the password, as above. Its only job now: wrap the master key.
- DEK (Data Encryption Key) — one per file, generated fresh each time. Wrapped by the master key, never by the KEK.
master key (32 random bytes, generated
once at signup, never derived)
│
┌───────────────────┼───────────────────┐
│ │ │
wrapped under wrapped under wrapped under
Marie's KEK her laptop's a recovery key
(her password) device key (printed once)
Below it, the master key wraps a DEK for every statement
Marie uploads — one per file, generated fresh each time.
// Generated once, at signup. Never derived from anything.
const masterKey = await crypto.subtle.generateKey(
{ name: "AES-KW", length: 256 },
true, // extractable — WebCrypto refuses to wrapKey() a
// non-extractable key, because enveloping IS a
// form of extraction. A master key that could
// never be extracted could never gain an
// envelope: no password change, no new device,
// no recovery. See the sign-in below for the
// regime this draw is the exception to.
["wrapKey", "unwrapKey"]
);
const wrappedMasterKey = await crypto.subtle.wrapKey(
"raw", masterKey, kek, "AES-KW"
);
// 32 bytes become 40 — RFC 3394's 8-byte integrity check. This is what
// Comptio's server stores. masterKey and kek never leave the browser.
Change the password now, and exactly one thing happens: unwrap the master key with the old KEK, re-wrap it with the new one. Not one statement moves, because not one statement was ever protected by the password directly.
And on the next sign-in
// 1. Derive the stretching salt locally from the email. No network yet.
// 2. Argon2id → root secret. About a second.
// 3. HKDF → verifier → POST it → a session exists.
// 4. Now authenticated: fetch kekSalt and wrappedMasterKey.
// 5. HKDF → KEK, and finally:
const masterKey = await crypto.subtle.unwrapKey(
"raw", wrappedMasterKey, kek,
"AES-KW", // how it's wrapped
{ name: "AES-KW", length: 256 }, // what comes out
false, // extractable: false — for good
["wrapKey", "unwrapKey"]
);
That false is the entire point of the true above. The master key is extractable for the one ceremony that has to wrap it — signup, a password change, enrolling a device — and never again. Every ordinary session rematerialises it in the regime where no script, Comptio’s own included, can read its bytes back out. Unwrapping also authenticates: AES-KW rejects rather than yielding rubbish, so a wrong password fails here loudly instead of producing a plausible-looking key that corrupts everything it touches.
Where this leaves us
Comptio’s server now holds, for Marie: an email address, a verifier it can check but not invert, a random account salt, and forty bytes wrapping a key it has never seen. She can change her password, and it costs one re-wrap. Nothing in that list decrypts anything.
It’s a satisfying place to stop, and it’s very nearly where I published the first version of this article. The problem is that Comptio isn’t a keyring — it’s a bookkeeping app. What Marie gets back from an upload isn’t her file. It’s her transactions, in rows, in a table the server has to be able to query.
Part 2 is about where those rows live, which turns out to be the question that makes or breaks everything above. Along the way: why the name of a field is often more revealing than its value, how to keep a key in a browser that no injected script can steal, and why none of this — not this design, not any encryption design — would have stopped the actual tax administration breach. Plus what would have.