E2E Encryption in a Web App
E2E Encryption in a Web App: The Decisions Nobody Talks About
E2E Encryption in a Web App: The Decisions Nobody Talks About
Most write-ups about end-to-end encryption in web apps spend 80% of their time on AES-GCM and why you should use it. That part is actually fine – the Web Crypto API does most of the work. What's hard is everything around it: the UX traps, the auth flow edge cases, and the subtle ways you can silently break things.
This is about building optional E2E encryption into Sonaa, a personal productivity app. Users can choose "Basic" (server sees plaintext) or "Secure" (server stores task titles, notes and subtasks only as ciphertext). Here's what the interesting decisions looked like.
Update (October 2026): the password no longer reaches the server at all – the browser derives a separate sign-in key from the same Argon2id output and sends only that. One limit remains: the server delivers the app's code, so a compromised server could ship a modified app. The design below protects stored data and the password, not against a malicious or compromised app.
Decision 1: Two keys, not one
The obvious approach: derive a key from the user's password, encrypt their data with it. Done.
The problem: if you ever want to let users change their password without re-encrypting every piece of data, you're stuck. Re-encrypting all data on every password change is slow, and if it fails halfway through, you're in a corrupt state.
The standard solution is a two-key hierarchy:
Password + salt → Argon2id → KEK (Key Encryption Key)
↓
Random 256-bit DEK → encrypted with KEK → stored on server
↓
User data ← encrypted/decrypted with DEK
The DEK is generated once, randomly, and never changes. The KEK is derived from the password and only exists transiently. When you change your password, you re-derive a new KEK from the new password and re-wrap the DEK – a tiny operation, atomic, nothing to do with your actual data.
The server stores encrypted_key (the wrapped DEK), kdf_salt, and kdf (a version string like "argon2id:3:65536:1" so params can change in the future). The actual DEK never touches the server.
Decision 2: Where the key lives on the client
The DEK needs to survive page refreshes without requiring the user to re-enter their password on every load. Options:
sessionStorage– cleared on tab close, survives refresh. Problem: it's a string, so you'd store the raw key bytes. Inspectable.localStorage– persists across sessions. Same problem, plus it survives even when you want it gone.IndexedDB– can store aCryptoKeyobject directly, as an opaque handle. The browser's key storage.
We use IndexedDB and – critically – store the DEK as extractable: false. This means once it's stored, the raw key bytes can never be read back out by JavaScript. You can use the key to encrypt and decrypt, but you can't extract it to a string and send it anywhere.
return crypto.subtle.unwrapKey(
'raw',
wrapped,
kek,
{ name: 'AES-GCM', iv },
{ name: 'AES-GCM' },
false, // non-extractable
['encrypt', 'decrypt'],
);
The tradeoff: extractable: false means you can't wrap the DEK again with a new KEK when the user changes their password. You need the DEK to be extractable at that moment. The solution: the DEK loaded from IndexedDB during a password-change flow uses a cached in-memory reference that was stored as extractable during that session (when the user first logged in and decrypted it to put into memory). The one stored in IndexedDB is non-extractable. Two different CryptoKey handles, same underlying key bytes, different extractability. The in-memory one is used for the re-wrap, then discarded.
Actually, the real answer is simpler: loadDEK() returns whatever is in IndexedDB. At login time, we use unwrapKey with extractable: false and store that. For the change-password flow, we load from IndexedDB and pass it directly to encryptDEK, which calls wrapKey. Since wrapKey works on a non-extractable key (you're wrapping it for export, not exporting it raw), this works fine. The browser allows wrapping a non-extractable key – it just won't let you call exportKey on it directly.
Decision 3: What to encrypt
Not everything. Specifically:
Encrypted: title, note, subtasks on tasks and anchors.
Plaintext: id, date, calendarId, shelfId, position, completed, timestamps.
The server needs the plaintext fields to do its job: sync logic, ordering, deduplication. The encrypted fields are the ones that contain user-readable content – what a task is called, notes, checklist items.
This is an intentional tradeoff. A truly paranoid design would encrypt everything and put the sync logic entirely on the client. That's significantly more complex. For this app, the server knowing you have a task on a particular date is acceptable; knowing what the task says is not.
Decision 4: Change password is safe. Reset password destroys data.
This is the sharpest edge in the whole design.
Change password is safe because the user knows their current password. The flow:
- Client derives a new KEK from the new password + a fresh salt
- Client loads the DEK from IndexedDB
- Client wraps the DEK with the new KEK →
newEncryptedKey - Client POSTs
{ currentPassword, newPassword, newEncryptedKey, newKdfSalt, newKdf }to a custom endpoint - Server verifies
currentPassword, then atomically updates bothaccount_.password(the auth hash) anduser_.encrypted_keyin one DB transaction
If the transaction fails, neither changes. The DEK is safe, the password is safe. The user can try again.
Reset password is fundamentally different. The user doesn't know their current password. There's no KEK to decrypt the DEK with. There's no way to re-wrap the DEK under the new password. The encrypted data is permanently inaccessible.
The naive approach is to silently break the user's data – let the password reset succeed, but now the DEK stored on the server is garbage (it was wrapped under the old KEK, which is gone). Next login: the app tries to decrypt the DEK with the new KEK, fails with a cryptographic error, and the user's data is either gone or they're locked in a broken state.
Our approach: block the reset flow entirely for encrypted users, at the email level.
Decision 5: The sendVerificationOTP interception trick
We use better-auth's emailOTP plugin for auth flows. It has a sendVerificationOTP callback that fires whenever an OTP needs to be sent, with the type ('email-verification' or 'forget-password').
The trick: for type === 'forget-password', check if the user has encryption. If they do, send a different email – one that explains why the reset is blocked – and return early without sending the OTP:
async sendVerificationOTP({ email, otp, type }) {
if (type === 'forget-password') {
const hasEncryption = await UserRepository.hasEncryptionByEmail(email);
if (hasEncryption) {
await sendEmail({
email,
subject: `Your ${APP_NAME} password reset request`,
html: `...
<h2>Password reset blocked</h2>
<p>Your account uses end-to-end encryption. Resetting your password
without knowing the current password would permanently destroy all
your encrypted data.</p>
<p>If you still know your current password, sign in and go to
Settings → Change password.</p>
...`,
});
return; // OTP was generated but never sent → effectively blocks the flow
}
}
// normal OTP email for non-encrypted users and email-verification
}
The OTP was generated by better-auth and stored in the database, but the user never received it. The reset flow is blocked at the email step, not at the API level. The user gets a clear explanation of why and what to do instead.
This is gentler than returning an API error: the UX from the user's perspective is "I requested a reset, I got an email" – the email just contains different content than they expected.
For users who no longer know their password and accept data loss, the email tells them to contact support. We handle that manually: wipe encrypted_key, kdf_salt, kdf, set encryption_method back to 'basic', and let them reset. Their data is gone but their account works.
Decision 6: Enabling encryption after signup
Users start on "Basic." Enabling encryption is an opt-in in Settings. The flow:
- User enters their current password
- Generate a fresh random DEK
- Derive KEK from password + new salt
- Wrap DEK with KEK
- POST to
/api/user/enable-encryption(server verifies the password against the stored hash, stores the wrapped key) - Re-encrypt all existing tasks and anchors in IndexedDB, bulk-write ciphertext
- Clear and re-enqueue the sync outbox so the ciphertext gets synced to the server
Step 6 is where bugs hide. Before we added it, enabling encryption would leave all existing tasks as plaintext in IndexedDB. They'd sync to the server as plaintext. The in-memory layer thought encryption was on and would try to decrypt them on read – and crypto.subtle.decrypt would throw on what it saw as corrupt ciphertext. The fix: re-encrypt everything in place, clear the outbox, re-enqueue.
Decision 7: The sync outbox is a poison pill
This isn't specific to encryption, but encryption made it much more visible.
Sync works with an outbox: local changes are queued as { entityType, entityId, operation, payload } entries. A background push() call sends the entire outbox in one POST. The server Zod-validates the whole body and returns a 400 if anything is malformed. The client increments a retry counter on failure and tries again later.
The problem: one bad entry blocks everything. If you have 50 pending changes and one of them has a malformed payload – say, a task where the title field is a string instead of a base64 ciphertext blob – the server 400s the entire batch, the retry counter increments on the bad entry, and the other 49 entries can never sync until you fix or remove the bad one. Silently. No UI indication that sync has been broken for days.
This happened in production: the account silently failed to push for three weeks during a period when the shelf feature was changing its sync schema. The server was healthy; a single stale outbox entry was poisoning the whole queue. We noticed because the data on the server was weeks out of date.
The right fix is per-entry validation: parse the Zod error paths from the 400 response body, quarantine entries that fail validation, let the rest through. Not implemented yet. The workaround was clearing the outbox manually.
What's not done yet
Recovery codes. The hardest problem. A recovery code is essentially an alternative KEK — you encrypt the DEK with it and print/store the code offline. If the user loses their password, they can use the code to recover the DEK. The tricky part is the UX: you have to force the user to actually save the code (or at minimum, acknowledge they're not saving it and accept data loss), and you need a way to revoke a code without breaking everything.
Disabling encryption. Currently one-way: Basic → Secure. The reverse path (decrypt all data, clear the keys from the server) exists in the codebase as clearEncryptionKeys but the UI flow for it isn't wired up.
Shared data. The current model is single-user. E2E encryption with sharing requires key agreement protocols (or a trust-on-first-use model), which is a substantially harder problem.
The crypto primitives are the easy part. Argon2id is well-supported via hash-wasm, Web Crypto API handles AES-GCM without any dependencies, and the two-key hierarchy is a standard pattern. What took time was the edge cases around auth flows that were designed for a world without client-side key derivation: password reset, account recovery, and the fact that any code path that changes the password has to atomically update the wrapped key at the same time or you've silently bricked the user's data.