Implementing End-to-End Encryption (E2EE) in Web Chat Applications

A step-by-step guide to building a secure end-to-end encrypted messaging pipeline using modern cryptographic primitives.

Implementing End-to-End Encryption (E2EE) in Web Chat Applications

In modern application security, protecting data in transit (via TLS) and at rest (via database encryption) is no longer sufficient for high-privacy environments like chat or financial communication. If your messaging server is compromised, an attacker can access the plaintext database or intercept active messages.

End-to-End Encryption (E2EE) guarantees that a message is encrypted directly on the sender’s local device and decrypted only on the recipient’s device. The intermediate servers act purely as blind relays, handling routing metadata but unable to read a single byte of message content.

In this deep dive, we’ll examine the cryptographic architecture behind E2EE, explain the Double Ratchet protocol popularized by Signal, and implement a concrete client-side E2EE pipeline using the modern Web Cryptography API.


1. The Cryptographic Building Blocks

E2EE relies on combining asymmetric (public-key) and symmetric cryptography:

Sender Device (Alice)                                 Recipient Device (Bob)
  │                                                     │
  ├─► 1. Fetch Bob's Public Key ────────────────────────┤
  ├─► 2. Generate Ephemeral Key & Perform ECDH ◄────────┤
  ├─► 3. Derive Shared Secret (HKDF)                    │
  ├─► 4. Encrypt Message (AES-256-GCM)                  │
  ├─► 5. Send Ciphertext + Ephemeral Public Key ───────►│
  │                                                     ├─► 6. Perform ECDH using Private Key
  │                                                     ├─► 7. Derive Shared Secret
  │                                                     ├─► 8. Decrypt Message (AES-256-GCM)
  1. Elliptic-Curve Diffie-Hellman (ECDH): Alice and Bob each have a public/private key pair (typically using Curve25519). Using ECDH, Alice can combine her private key with Bob’s public key, and Bob can combine his private key with Alice’s public key. Both calculations yield the exact same shared secret key, without ever sending that secret over the network.
  2. HKDF (HMAC-based Extract-and-Expand Key Derivation Function): A cryptographic function used to stretch the raw ECDH shared secret into high-entropy symmetric encryption keys.
  3. AES-GCM (Galois/Counter Mode): An Authenticated Encryption with Associated Data (AEAD) symmetric cipher. It encrypts the message and generates a cryptographic tag that guarantees the message has not been tampered with in transit.

2. Advanced Session Security: The Double Ratchet

Simply exchanging a single public key and using it forever is vulnerable. If an attacker eventually steals Bob’s private key, they can decrypt all historical messages. To prevent this, modern E2EE uses the Double Ratchet Protocol:

Forward Secrecy

If a session key is compromised today, the attacker cannot decrypt messages sent in the past.

Break-in Recovery (Post-compromise Security)

If an attacker compromises a session key today, they cannot predict or decrypt messages sent in the future, as the key is constantly rotated using fresh elliptic curve handshakes.

The protocol is called “double” because it combines two separate ratchets:

  1. KDF Chain Ratchet (Symmetric): For every single message sent, the symmetric key chain is rolled forward. The encryption key is used once and immediately destroyed.
  2. DH Ratchet (Asymmetric): Whenever a response is received, a new elliptic curve key exchange is performed, injecting fresh, unpredictable entropy into the symmetric key chain.

3. Implementation: Client-side E2EE with the Web Crypto API

Let’s implement a complete E2EE workflow in JavaScript using the browser’s built-in Web Cryptography API (window.crypto.subtle). This script runs entirely on the client, demonstrating key generation, shared secret derivation, and authenticated encryption/decryption.

// snippet-1
// Utility function to convert buffer to Base64 (for transmission)
function bufferToBase64(buf) {
    return btoa(String.fromCharCode.apply(null, new Uint8Array(buf)));
}

// Utility function to convert Base64 to buffer
function base64ToBuffer(base64) {
    const binaryString = atob(base64);
    const bytes = new Uint8Array(binaryString.length);
    for (let i = 0; i < binaryString.length; i++) {
        bytes[i] = binaryString.charCodeAt(i);
    }
    return bytes.buffer;
}

// 1. Generate ECDH Key Pair for Curve P-256
async function generateKeyPair() {
    return await window.crypto.subtle.generateKey(
        {
            name: "ECDH",
            namedCurve: "P-256"
        },
        true, // extractable
        ["deriveKey", "deriveBits"]
    );
}

// 2. Derive Symmetric AES-GCM Encryption Key from Local Private & Remote Public
async function deriveSharedKey(localPrivateKey, remotePublicKey) {
    return await window.crypto.subtle.deriveKey(
        {
            name: "ECDH",
            public: remotePublicKey
        },
        localPrivateKey,
        {
            name: "AES-GCM",
            length: 256
        },
        false, // not extractable (highly secure!)
        ["encrypt", "decrypt"]
    );
}

// 3. Encrypt Plaintext using AES-GCM
async function encryptMessage(sharedKey, plaintext) {
    const encoder = new TextEncoder();
    const encodedData = encoder.encode(plaintext);
    
    // Generate a 12-byte cryptographically secure initialization vector (IV)
    const iv = window.crypto.getRandomValues(new Uint8Array(12));
    
    const ciphertext = await window.crypto.subtle.encrypt(
        {
            name: "AES-GCM",
            iv: iv
        },
        sharedKey,
        encodedData
    );
    
    return {
        ciphertext: bufferToBase64(ciphertext),
        iv: bufferToBase64(iv)
    };
}

// 4. Decrypt Ciphertext using AES-GCM
async function decryptMessage(sharedKey, ciphertextBase64, ivBase64) {
    const ciphertext = base64ToBuffer(ciphertextBase64);
    const iv = base64ToBuffer(ivBase64);
    
    const decrypted = await window.crypto.subtle.decrypt(
        {
            name: "AES-GCM",
            iv: iv
        },
        sharedKey,
        ciphertext
    );
    
    const decoder = new TextDecoder();
    return decoder.decode(decrypted);
}

Complete Demo Execution Flow

Here is how you link the steps together to simulate Alice sending a secure message to Bob:

// snippet-2
async function runDemo() {
    console.log("--- E2EE Demo Starting ---");
    
    // Step A: Alice and Bob generate their respective key pairs
    const aliceKeys = await generateKeyPair();
    const bobKeys = await generateKeyPair();
    
    // Step B: Alice fetches Bob's Public Key and derives the Shared Key
    const aliceSharedKey = await deriveSharedKey(aliceKeys.privateKey, bobKeys.publicKey);
    
    // Step C: Alice encrypts her secret message
    const message = "Meet me at the secure terminal at midnight.";
    const encryptedPayload = await encryptMessage(aliceSharedKey, message);
    console.log("Encrypted Payload:", encryptedPayload);
    
    // Step D: Bob receives the ciphertext, fetches Alice's Public Key, and derives the Shared Key
    const bobSharedKey = await deriveSharedKey(bobKeys.privateKey, aliceKeys.publicKey);
    
    // Step E: Bob decrypts the payload
    const decryptedMessage = await decryptMessage(
        bobSharedKey, 
        encryptedPayload.ciphertext, 
        encryptedPayload.iv
    );
    console.log("Decrypted Message:", decryptedMessage);
    
    if (message === decryptedMessage) {
        console.log("SUCCESS: End-to-End Encryption verified!");
    }
}
runDemo();

Summary

Building end-to-end encrypted applications places the security boundary where it belongs: on the user’s device. By combining Curve25519/P-256 elliptic key exchanges, modern key derivation functions, and robust authenticated encryption schemes, you can build next-generation applications that remain completely secure, even if your backend servers are fully compromised.