Engineering · encryption
Encrypting User Data Without Holding the Key, Part 2: Where the Data Actually Lives
by Sylvain Artois on Aug 30, 2026
- #encryption
- #key-management
- #envelope-encryption
- #aes-gcm
- #indexeddb
- #webcrypto
- #javascript
- #data-breaches
- #gdpr
Part 1 built Marie a keyring — a master key drawn in her browser at signup, wrapped under a key derived from her password, stored on the server as forty useless bytes. Changing her password re-wraps one key and moves not a single file. That was the satisfying part. This is the part where it meets an actual product, and where I found out that the article I thought I was writing was about a third of the problem.
Where Part 1 left off
- Comptio is invented — a small SaaS where freelancers upload a monthly bank statement export and get help reconciling their books. Marie has been uploading hers for three years.
- Her browser holds a master key: unwrapped at sign-in with a KEK (key encryption key) derived from her password, non-extractable from that moment on, never transmitted.
- Comptio’s server holds the envelope around it and cannot open it. Every file gets its own DEK (data encryption key), wrapped under the master key with AES-KW (AES Key Wrap) — the textbook envelope encryption layout.
Uploading a statement, for real
const dek = await crypto.subtle.generateKey(
{ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]
);
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv }, dek, statementBytes
);
const wrappedDek = await crypto.subtle.wrapKey(
"raw", dek, masterKey, "AES-KW"
);
// Comptio's server receives: ciphertext, iv, wrappedDek.
// Marie's statement and its DEK never leave the browser in clear.
The IV (initialisation vector) is 96 bits from the CSPRNG (cryptographically secure pseudorandom number generator, here crypto.getRandomValues), and it’s random rather than a counter for a structural reason rather than a stylistic one: this DEK encrypts exactly one message, ever. There is no counter to keep because there is no second message. The rule you must never break with AES-GCM (Advanced Encryption Standard in Galois/Counter Mode) — never reuse a (key, IV) pair — is satisfied here by the shape of the design rather than by a discipline someone has to remember.
The file is not the data
Comptio now stores a statement it cannot read. That would be the end of the article if Comptio were a filing cabinet. It isn’t. The entire product is that Marie uploads a file and gets back transactions — dated, labelled, categorised, ready to reconcile. Those rows are the data. The .xlsx is the envelope it arrived in.
So where do the rows live?
This is the question I skipped on my first pass, and skipping it makes the whole keyring theatre. A server that stores statement-2026-03.xlsx under a key it doesn’t have, and then stores 2026-03-14 | 1200.00 | client: Dupont SARL in an ordinary indexed table beside it, has encrypted the packaging and published the contents. The breaches this pair of articles opened with were not stolen attachments. They were rows.
Reading it once, on purpose
Somebody has to turn the file into rows, and for Comptio that somebody is the server — it runs the parsing, and for the messier statements a model call. It cannot do that under a key it never holds.
So the key release is made a gesture, with three properties:
- Marie’s browser unwraps the statement’s DEK and sends it naked, in the body of the request that carries the gesture. Never in a URL, never in a header — both have far more journals than a body does: proxies, access logs, browser history, referrers.
- The key is used inside that request and parked nowhere. No job queue holding it for a worker to pick up later, no cache, no temporary row.
- Consent is per document and revocable — and the revocation means something precisely because of the point above. The server doesn’t destroy a key it kept. It never kept one. Revocation stops being a procedure and becomes a fact.
That’s the honest version of “one moment of plaintext”. Not a vague admission in a limitations section: a specific request, triggered by a specific gesture, during which a key exists on the server for the duration of one HTTP call.
Sealing the rows back down
What comes out of that call is a list of (field, value) pairs, and both halves are a problem.
The value half is the one everyone thinks of, and Part 1’s tree almost solves it — except that a row is not a file. Hanging one key per row off the master key would mean releasing hundreds of keys on a screen that shows a whole month. So one floor is added:
master key
└── profile key one per person, born at signup, wrapped under the master key
└── value key one per row, drawn fresh by whoever writes it
└── AES-256-GCM(value, value key, AAD = the clear field name)
The unit of release is the unit of reading. One DEK for a document, because you read one document at a time. The profile key for the rows, because the screen that reconciles a month reads all of them at once.
There’s a second reason for that floor, and it’s the one that isn’t symmetry for symmetry’s sake: the server writes rows too. A recomputed total, a category corrected by a rule, a validated extraction. A server that must wrap cannot wrap under a key it never holds — so one key has to stand between, released per gesture, rather than N keys released per call.
Then the AAD (additional authenticated data, the “AD” in AEAD, passed to WebCrypto as additionalData), which the first draft of this article was missing entirely:
const aad = new TextEncoder().encode(`comptio:value:v1\0${fieldName}`);
const sealed = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv, additionalData: aad },
valueKey,
new TextEncoder().encode(value),
);
Associated data is authenticated but never stored. It has to be presented again, byte for byte, at decryption, or the open fails. Binding the clear field name means a value moved under another field does not open. Without it, anyone with write access to the database can swap two rows’ ciphertexts — and swapping monthly_revenue with rent is a perfectly good attack that touches no key at all.
The name of the field is the sensitive fact
Now the half nobody sees coming. I found it in an audit that produced one sentence I still think about: the code encrypted everything called “value”, and left in clear everything called something else.
Think about what that means for a row. A row whose field is collective_proceedings says the business is in trouble whatever its value is. It’s the existence of the row that informs, not its contents. And the column is indexed, because the server has to query it.
Which is exactly the difficulty: the field name can’t be sealed under Marie’s keys, because the server has to WHERE field = ? without her in the room — scheduled recomputation, the unique constraint that keeps one active value per field, every derivation. Determinism is the requirement, and determinism is what an envelope exists to refuse.
The answer is a keyed pseudonym (pseudonymisation in the GDPR Article 4(5) sense): an HMAC (hash-based message authentication code) of the name under a key the server holds, truncated to a fixed width.
- The same name always produces the same code, so indexes, unique constraints and queries keep working untouched. The join back to the human-readable catalogue moves into the application.
- The code can’t be reversed without the key, even though the catalogue of field names is public — which is why it’s an HMAC and not a plain digest. A digest of a few thousand known names is a rainbow table somebody builds in an afternoon.
- The width is fixed, because a variable-length code leaks the length of the original name, and over a small public vocabulary that’s a partition of it.
Two details that look like over-engineering and aren’t:
Every name is pseudonymised, with no exclusion list. Hashing only the few dozen genuinely revealing names looks obviously cheaper. It’s also self-defeating — the fact of being hashed becomes the signal, and the attacker’s search space collapses from the whole vocabulary to that handful. A curated list is also an object somebody has to maintain, and the day a new field ships in clear, nothing turns red.
The pseudonym carries a key version and nothing else — v2.k3xq7m…. Rotating this key means rewriting every field column in the database, so the prefix is what lets that rotation be progressive instead of one enormous transaction: new rows carry the new prefix, old rows keep resolving under the old key.
The honest accounting
This is the one place in the whole design where the server holds a secret that matters, and it’s worth being precise about what that buys and what it doesn’t.
It is not user-held, and I’m not going to pretend otherwise. A dump of the database alone reveals neither field names nor values. A dump plus the pseudonymisation key reveals which questions a person answered — not a single answer, but the shape of their file. That’s real, and it’s the price of a server that can query the data at all.
I didn’t find a way around it, and I’d rather say that plainly than dress it up. You can have a server that indexes your rows, or you can have field names only the person can read. Not both.
The key that lives in a browser and cannot leave it
Marie shouldn’t retype her password every time she opens a tab. The usual answer is to keep something in localStorage, and the usual answer is wrong: localStorage and sessionStorage take strings. A key put there is a key in the clear, and any injected script on the origin (XSS, cross-site scripting) reads it and posts it somewhere.
There’s a better option, and it’s one of those browser capabilities almost nobody uses.
IndexedDB stores structured clones, and a CryptoKey survives one — including its extractable: false.
const deviceKey = await crypto.subtle.generateKey(
{ name: "AES-KW", length: 256 },
false, // extractable: false — for good, this time
["wrapKey", "unwrapKey"]
);
// Stored as a CryptoKey object. Not as bytes: there are no bytes to store.
const tx = db.transaction("keys", "readwrite");
tx.objectStore("keys").put(deviceKey, "current");
// One more envelope around the same master key.
const wrappedForDevice = await crypto.subtle.wrapKey(
"raw", masterKey, deviceKey, "AES-KW"
);
The raw material never exists in JavaScript at all — not before the put, not after the get. WebCrypto’s engine holds it; your code holds a handle. An injected script can still use that handle while the tab is open, which is irreducible in a browser and which I’ll come back to. What it cannot do is exfiltrate the key. That difference is worth the detour.
Three things I got wrong on the way to a version I’d ship.
1. The store is same-origin, so anything can write to it
Non-extractability guards the door out. It says nothing about the door in. IndexedDB is same-origin storage: a script that got onto the page can put a key of its own at exactly these coordinates — an extractable one, one of the wrong algorithm, or a plain object wearing the right property names — and the record looks legitimate from outside.
You can’t stop that write. What you can do is refuse to hand back anything that isn’t the shape you mint:
function rejection(found) {
if (!(found instanceof CryptoKey)) return "not-a-crypto-key";
if (found.extractable !== false) return "extractable";
if (found.type !== "secret") return "wrong-type";
if (found.algorithm.name !== "AES-KW") return "wrong-algorithm";
if (found.algorithm.length !== 256) return "wrong-length";
// ...and the exact usage set, sorted and compared
return null;
}
Refusing extractable: true is the check people leave out, and it’s the important one: a key whose bytes come back out is not one this code ever wrote, so using it would mean using material somebody else chose. Note also that the refusal carries a reason code and never the value it refused — a rejected record is exactly the kind of thing whose bytes must not reach a log line.
And note what this is not, because I nearly shipped it believing more than it delivers. A shape is not a provenance. A script that can plant a record can plant one of exactly this shape; importKey over bytes of its choosing is one line, and no property of a CryptoKey says who minted it. The guard removes sloppy plants and accidental corruption. The attack it doesn’t answer is substitution — a key of the right shape and the wrong origin, read back by a later, clean session.
Closing that needs the key to be authenticated by use: an unwrap only the real key can perform, against material the server holds. Which this design already has sitting right there — the wrapped master key. If unwrapping it with this device key fails, the device key wasn’t ours. The guarantee is free; it just has to be the unwrap rather than the shape.
2. “Wipe on sign-out” wipes nothing
A key cleared on sign-out and on session expiry survives a closed browser, a crashed tab, and a session killed server-side while nothing was listening. An expiry timer only ticks in a live tab — which is precisely the wrong answer wearing the right words.
So the wipe is unconditional, on every page load of a signed-out visitor, before anything else runs. One gesture, idempotent, both records in a single transaction — because a wipe that could end one key and not the other would leave a master key alive behind a device key that’s gone.
3. Checking whether a key exists shouldn’t create a database
This one isn’t a security bug, it’s a legal one, and I like it for that. indexedDB.open(name, version) creates the database when it’s absent. So a wipe-on-every-boot writes comptio-keys into the terminal equipment of a visitor who has no account and never will — by the very act of checking that they hold no key.
indexedDB.databases() lists what exists and creates nothing. One probe, and every uncertainty answers “wipe anyway”: a platform that doesn’t implement it, a probe that throws, a listing you can’t read. The failure being avoided is a key outliving its session; a database created for nothing is a cost, never a danger.
Two smaller ones, for whoever implements this:
- Resolve on the transaction’s
complete, not the request’ssuccess. That’s the difference between “the write was accepted” and “the write is durable” — a caller that awaitssuccesscan reload the page before the transaction commits. And attach every handler synchronously: anawaitbetween opening a transaction and wiring its handlers is how an IndexedDB transaction commits behind your back. clear()the store, neverdeleteDatabase(). Deleting a database blocks on every open connection in every tab, which leaves the wipe pending in exactly the case you built it for: a second tab.
Recovery is one more envelope
The third branch of Part 1’s diagram, for completeness, because it’s the same two lines as the other two:
const recoveryBytes = crypto.getRandomValues(new Uint8Array(32));
const recoveryKey = await crypto.subtle.importKey(
"raw", recoveryBytes, "AES-KW", false, ["wrapKey", "unwrapKey"]
);
const wrappedForRecovery = await crypto.subtle.wrapKey(
"raw", masterKey, recoveryKey, "AES-KW"
);
Those 32 bytes get shown to Marie once, as a printable code. Comptio stores the envelope and never the code. No statement is touched, no re-encryption happens — the pattern holds however many envelopes you add, which was the whole point of putting the master key in the middle.
One thing it buys that isn’t security
Destroying the envelopes is a cryptographic erasure (what NIST SP 800-88 files under sanitisation). When Marie closes her account, deleting the handful of rows that wrap her master key makes every statement and every value she ever stored permanently unopenable — including the copies sitting in last night’s backup, which no DELETE will ever reach.
Worth being careful about how much weight that carries. It’s one more guarantee on the path to GDPR Article 17, not a substitute for actually deleting the data: the rows still go, the blobs still get purged, the cascade still has to be right and tested. What the erasure adds is that the window between “the person asked” and “the last replica is gone” stops being a window in which the data is readable. For a backup retention policy measured in weeks, that isn’t nothing.
What this still doesn’t cover
- A compromised client. If Comptio’s own JavaScript is tampered with, it sees the KEK and the master key while it runs — because it has to, to do its job. No key scheme changes that. Worth noting the profile key is worse here than the master key: it has to stay extractable for as long as the browser holds it, since releasing it means exporting it. A captured profile key opens the rows written after the capture too, until the person rotates it.
- A moment of real plaintext, on purpose. Described above, in as much detail as I could manage: one request, one gesture, one key that’s parked nowhere. That is the honest limit flagged in Part 1. This is not zero-knowledge, and claiming otherwise while also offering that feature wouldn’t survive a real audit.
- Recovery is a backdoor. If Marie loses every enrolled device and her printed code and forgets her password, someone has to be able to help her back in, or she loses three years of bookkeeping. The honest move isn’t pretending that path doesn’t exist — it’s making it loud: initiated by her, notified on every channel she has, logged, and delayed long enough that a fraudulent attempt looks exactly like what it is before it succeeds.
Back to the breach
None of the above is what stopped, or would have stopped, the two real incidents this pair of articles opened with.
- February 2026: someone impersonated a civil servant with legitimate cross-agency access and read roughly 1.2 million entries from a national bank-account registry — account holder identity, address, IBAN.
- Summer 2026: an intrusion into the tax administration’s own systems was caught and cut off in late June — the public only learned of it seven weeks later, in mid-August, when an attacker chose to announce a claimed theft of several hundred thousand tax records.
Officials described both plainly: the breach came from a real account being used, not from a flaw being exploited. That distinction is the whole point of this pair of articles. Whatever key-management design sits underneath a system like that — server-held or user-held, it makes no difference here — it protects data that has come loose from the system it belongs in. It has nothing to say about a login that was let in through the front door and did something the system was built to allow. Ten queries a minute looks identical to ten thousand, to a piece of cryptography.
Where the real defence lives
A few directions, each cheaper to build than everything above, and each aimed at what encryption cannot touch:
- A hard ceiling on reads per session, per hour. Rate limiting turns an incident that could expose hundreds of thousands of rows into one that exposes a few dozen before it’s cut off automatically.
- A handful of very specific alarms, not a general intrusion system tuned to flag everything. A rule that fires zero times a week in normal operation is one a small team actually looks at — a noisy one gets disabled within a month, which is its own well-documented failure mode, alarm fatigue.
- An audit trail that can’t be quietly edited after the fact, so “what did they actually see” has an answer instead of a guess reconstructed under pressure.
- Phishing-resistant hardware keys for anyone with elevated access, instead of a password and an SMS code — see webauthn.guide for how that actually works under the hood. In both incidents above, the front door wasn’t picked. It was walked through with someone else’s key.
None of that is cryptography. All of it is what makes a lock’s promise mean something — including GDPR’s own 72-hour clock, which starts the moment you know, not the moment you’re ready to say so.