End-to-End Encryption
Even your server can't read the data. Only the user's device can decrypt it. The server is just a dumb relay for ciphertext.
01What it actually is
- Keys live only in user devices, derived from their password
- If you log into the database, you see only
ciphertext: "AbCdEf..." - If a hacker steals your entire server, they get nothing useful
- Forgot password? Your data is gone forever — server can't reset it
- You cannot run
WHERE message LIKE '%refund%'queries — server can't search encrypted data
WhatsApp, Signal, Telegram secret chats, ProtonMail, 1Password, Bitwarden. Apps where the entire value proposition is "we couldn't read your data even if we wanted to."
02Node.js side
Notice what's missing: there is no encryption or decryption logic on the server at all. The server stores and forwards encrypted blobs. It has no idea what's inside them.
// Notice: NO encryption/decryption logic on server!
// Server just stores and forwards encrypted blobs.
app.post('/api/messages', authMiddleware, async (req, res) => {
// req.body looks like: { to: 'user_42', ciphertext: 'AbCdEf...' }
// Server has NO idea what's inside ciphertext
await Message.create({
fromUserId: req.user.userId,
toUserId: req.body.to,
ciphertext: req.body.ciphertext // store as-is
});
res.json({ success: true });
});
app.get('/api/messages', authMiddleware, async (req, res) => {
const msgs = await Message.find({ toUserId: req.user.userId });
// Send back encrypted blobs. Server NEVER decrypted them.
res.json(msgs);
});
03Angular side
All the cryptography happens in the browser. The user's private key is derived from their password — never stored, never transmitted. The server cannot reset what it never had.
// e2ee.service.ts — encryption happens ENTIRELY in the browser
@Injectable({ providedIn: 'root' })
export class E2EEService {
// User's private key derived from their PASSWORD (server never sees it)
private myPrivateKey: CryptoKey | null = null;
async unlockKey(userPassword: string) {
// Derive key from password using PBKDF2 — slow on purpose
const salt = new TextEncoder().encode('your-app-salt');
const passwordBytes = new TextEncoder().encode(userPassword);
const baseKey = await crypto.subtle.importKey(
'raw', passwordBytes, { name: 'PBKDF2' }, false, ['deriveKey']
);
this.myPrivateKey = await crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations: 600000, hash: 'SHA-256' },
baseKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
// ⚠️ Server NEVER sees this key, never sees the password
}
async sendMessage(toUserId: string, plaintext: string) {
// Encrypt with recipient's public key (fetched separately)
const recipientPubKey = await this.fetchPublicKey(toUserId);
const iv = crypto.getRandomValues(new Uint8Array(12));
const enc = await crypto.subtle.encrypt(
{ name: 'RSA-OAEP' },
recipientPubKey,
new TextEncoder().encode(plaintext)
);
// Send only the ciphertext blob
return this.http.post('/api/messages', {
to: toUserId,
ciphertext: this.toBase64(new Uint8Array(enc))
});
}
async readMessage(encryptedBlob: string) {
// Decrypt with MY private key — only my browser can do this
const buf = this.fromBase64(encryptedBlob);
const plain = await crypto.subtle.decrypt(
{ name: 'RSA-OAEP' },
this.myPrivateKey!,
buf
);
return new TextDecoder().decode(plain);
}
}
04The hard trade-offs
E2EE is the strongest model — and the most expensive in product terms. The constraints below are not bugs. They are direct consequences of "the server doesn't have the key."
| Capability you lose | Why |
|---|---|
| Password reset | Server can't decrypt without the password. Reset = data loss. |
| Server-side search | WHERE body LIKE '%foo%' doesn't work on ciphertext. |
| Server-side analytics on content | Same reason — content is opaque to the server. |
| Push notifications with content preview | Notification servers see only ciphertext. |
| Web access from a fresh device | The device must re-derive or import the key — usually via password re-entry or an out-of-band setup. |
| Multi-device sync | Solvable, but requires careful key-distribution protocols (e.g. Signal's sealed-sender, MLS). |
05What it protects (and what it doesn't)
| Threat | Protected? | Why / Why not |
|---|---|---|
| Full server compromise (attacker has root) | Yes | Database holds only ciphertext. No keys on the server. |
| Subpoena / legal request to read content | Yes | You cannot hand over what you don't have. |
| Insider threat — engineer with DB access | Yes | Same reason. The DB contains no plaintext. |
| Compromised user device | No | If the attacker is on the device, they have the key. |
| Malicious app update that exfiltrates plaintext | No | The client is part of the trust boundary. Reproducible builds and code-signing help. |
06Key takeaways
- Code complexity: Hard. Key derivation, public-key fetch, multi-device sync — these are not weekend problems.
- Server sees data: Never. That's the entire point.
- Use case: Messaging, password vaults, secure document sharing — anywhere "we can't read your data" is the product.
- Performance cost: 5–15 ms per message (RSA wrap), plus a one-time ~1 ms session setup.
- Don't build this casually: Use a vetted library or protocol (Signal Protocol, MLS, age, libsodium). Rolling your own E2EE is the canonical way to ship insecure software while feeling secure.
Skip Level 4 unless you're building a messaging app or a password vault. The trade-offs (lost password reset, no search, no analytics) are not worth it for a typical CRUD app — and the cryptographic engineering is real.