Level 04 — Maximum

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

→ Real-world example

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.

routes/messages.js
JavaScript
// 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
TypeScript
// 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

→ For your project

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.