How to Implement End-to-End Encryption with Key Exchanges Like WhatsApp in Any App
By Vinay Sharma

End-to-end encryption (E2EE) is the property where messages are encrypted on the sender's device and can only be decrypted on the recipient's device — the server in the middle carries ciphertext it cannot read. WhatsApp, Signal, and iMessage all use it. If you handle health, financial, legal, or any sensitive customer data, E2EE is increasingly an expectation, not a feature.
This post is a practical, end-to-end guide to implementing WhatsApp-style E2EE in a real app: threat model, the Signal-style key exchange (X3DH), forward secrecy via the Double Ratchet, persistent storage, multi-device sync, and the pitfalls that break E2EE in production. Code samples are in TypeScript so they translate directly to React Native, Node.js, or the browser.
What this post is not
A from-scratch crypto guide. We use vetted primitives from libsodium and @noble/curves — never hand-rolled cryptography. "Don't roll your own crypto" is not a suggestion; it's a survival rule.
1. Get the Threat Model Right First
E2EE means nothing without a clear threat model. The three questions you must answer before writing any code:
- Who is the attacker? A network eavesdropper? Your own database admin? A subpoena-holding government? A malicious user on a shared device?
- What are you protecting? Message contents? Metadata (who talked to whom, when)? Attachment files?
- What's out of scope? Compromised endpoints (malware on the user's phone) is almost always out of scope — if the device is rooted, E2EE cannot save you.
The classic E2EE threat model assumes a honest-but-curious server: the server stores and routes ciphertext, never sees plaintext, and an attacker who fully compromises the server still cannot decrypt past or future messages. That's the model we build to here.
2. The Cryptographic Primitives You'll Need
Install these and don't reach for anything else:
bun add libsodium-wrappers-sumo @noble/curvesYou'll use four primitives:
| Primitive | What it does | Where |
|---|---|---|
| X25519 | Ephemeral key agreement (Diffie-Hellman) | @noble/curves |
| Ed25519 | Long-term identity signing | @noble/curves |
| XChaCha20-Poly1305 | Authenticated symmetric encryption (AEAD) | libsodium |
| HKDF-SHA256 | Derive multiple keys from a shared secret | @noble/hashes |
Why XChaCha20-Poly1305 and not AES-GCM? XChaCha has a 192-bit nonce, so you can generate nonces randomly without worrying about collisions — a common footgun with AES-GCM's 96-bit nonce. ChaCha20 is also constant-time and faster in software without AES-NI.
3. The Signal Protocol in 200 Words
WhatsApp's E2EE is a fork of the Signal Protocol. It has three pieces:
- Identity keys — a long-term Ed25519 signing key per user, uploaded to the server once.
- X3DH (Extended Triple Diffie-Hellman) — a one-time key agreement that establishes the first shared secret when Alice and Bob start talking. It mixes four X25519 keys to derive a strong root key even if some of them are pre-published.
- Double Ratchet — a stateful algorithm that rotates message keys on every send and every receive. It provides forward secrecy (compromising current keys doesn't reveal past messages) and post-compromise security (compromising current keys self-heals after a few messages).
The result: every message uses a fresh key, the server only ever sees ciphertext, and a one-time compromise doesn't unravel the conversation.
4. Step 1 — Identity Keys and Pre-key Bundles
Every user generates an identity key pair once, on-device, and never exports the private half.
import { ed25519, x25519 } from "@noble/curves/ed25519";
import { randomBytes } from "@noble/hashes/utils";
// Long-term identity (Ed25519 for signing)
const identityPriv = ed25519.utils.randomSecretKey();
const identityPub = ed25519.getPublicKey(identityPriv);
// Convert Ed25519 → X25519 for DH (the "identity DH key")
const identityDhPriv = ed25519.ed25519SecretKeyToX25519(identityPriv);
const identityDhPub = x25519.getPublicKey(identityDhPriv);To start a conversation, Alice needs Bob's pre-key bundle: his identity key, a signed pre-key, and a stack of one-time pre-keys. The signed pre-key rotates every few weeks; one-time pre-keys are consumed once and discarded.
// Bob publishes a signed pre-key (rotated ~monthly)
const signedPreKeyPriv = x25519.utils.randomPrivateKey();
const signedPreKeyPub = x25519.getPublicKey(signedPreKeyPriv);
const signedPreKeySig = ed25519.sign(identityPriv, signedPreKeyPub);
// Bob publishes many one-time pre-keys (consumed per session)
const oneTimePreKeys = Array.from({ length: 100 }, () => {
const priv = x25519.utils.randomPrivateKey();
return { priv, pub: x25519.getPublicKey(priv) };
});The signed pre-key signature is what lets Alice verify — without trusting the server — that the pre-key really came from Bob.
5. Step 2 — X3DH: Establishing the First Shared Secret
When Alice wants to message Bob for the first time, she fetches his pre-key bundle and computes a shared secret by combining four X25519 DH operations:
| DH | Alice's key | Bob's key | Purpose |
|---|---|---|---|
| DH1 | Alice identity (DH) | Bob identity (DH) | Long-term binding |
| DH2 | Alice identity (DH) | Bob signed pre-key | Bob is who he claims |
| DH3 | Alice ephemeral | Bob signed pre-key | Forward secrecy |
| DH4 | Alice ephemeral | Bob one-time pre-key | Extra freshness |
import { x25519 } from "@noble/curves/ed25519";
import { hkdf } from "@noble/hashes/hkdf";
import { sha256 } from "@noble/hashes/sha2";
function dh(priv: Uint8Array, pub: Uint8Array): Uint8Array {
return x25519.getSharedSecret(priv, pub);
}
function x3dh(
aliceIdentityDhPriv: Uint8Array,
aliceEphemeralPriv: Uint8Array,
bobIdentityDhPub: Uint8Array,
bobSignedPreKeyPub: Uint8Array,
bobOneTimePreKeyPub: Uint8Array | null,
) {
const dh1 = dh(aliceIdentityDhPriv, bobIdentityDhPub);
const dh2 = dh(aliceEphemeralPriv, bobIdentityDhPub);
const dh3 = dh(aliceEphemeralPriv, bobSignedPreKeyPub);
const dh4 = bobOneTimePreKeyPub
? dh(aliceEphemeralPriv, bobOneTimePreKeyPub)
: new Uint8Array(32);
// Concatenate in EXACT order — Signal spec.
const ikm = concat(dh1, dh2, dh3, dh4);
const info = new TextEncoder().encode("ImpacttX_E2EE_v1_X3DH");
return hkdf(sha256, ikm, salt, info, 64); // 64 bytes: root key + chain key
}The output splits into a root key and an initial chain key, both of which feed the Double Ratchet. Alice then deletes her ephemeral private key — it served its purpose.
Critical: preserve the DH order
X3DH's security relies on the exact concatenation order: DH1 || DH2 || DH3 || DH4. Swap the order and the protocol is broken. Pin this to a unit test that matches the Signal spec.
6. Step 3 — The Double Ratchet: Forward Secrecy in Practice
The Double Ratchet is the engine that makes every message use a fresh key. It has two ratchets:
- Symmetric ratchet: every send/receive advances the chain key via HMAC. Each message key is one-shot.
- DH ratchet: every time the conversation changes direction, a new X25519 key pair is generated and mixed into the root key.
Here's the symmetric ratchet in 30 lines:
import { hmac } from "@noble/hashes/hmac";
import { sha256 } from "@noble/hashes/sha2";
const KDF_RK = new TextEncoder().encode("ImpacttX_E2EE_RK");
const KDF_CK = new TextEncoder().encode("ImpacttX_E2EE_CK");
// Advance the chain key → produce a one-shot message key
function ratchetChainKey(chainKey: Uint8Array) {
const nextChainKey = hmac(sha256, chainKey, new Uint8Array([0x02]));
const messageKey = hmac(sha256, chainKey, new Uint8Array([0x01]));
return { nextChainKey, messageKey };
}
// When the conversation changes direction, mix a new DH
function dhRatchet(
rootKey: Uint8Array,
ourDhPriv: Uint8Array,
theirDhPub: Uint8Array,
) {
const dhOut = dh(ourDhPriv, theirDhPub);
const derived = hkdf(sha256, dhOut, rootKey, KDF_RK, 64);
return {
rootKey: derived.slice(0, 32),
chainKey: derived.slice(32, 64),
};
}Encryption is a one-liner with libsodium:
import sodium from "libsodium-wrappers-sumo";
function encrypt(messageKey: Uint8Array, plaintext: Uint8Array) {
const nonce = sodium.randombytes_buf(24); // 192-bit, safe to random
const ciphertext = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(
plaintext,
null, // additional data (e.g., a header)
null,
nonce,
messageKey,
);
return { nonce, ciphertext };
}The recipient advances their chain key the same way, derives the same message key, and decrypts. Out-of-order messages are handled by skipping keys up to a reasonable window (typically 2,000 messages).
7. Step 4 — Secure Storage on Device
E2EE lives and dies on key storage. If the private keys leak, the whole protocol is theater.
- iOS: store keys in the Keychain with
kSecAttrAccessibleWhenUnlockedThisDeviceOnly. Use Secure Enclave for the identity key where possible (it never leaves the chip). - Android: store keys in the Android Keystore with
setUserAuthenticationRequired(true)and a biometric prompt. Use StrongBox on supported devices. - Web: use WebCrypto with non-extractable keys and IndexedDB for state. Accept that a persistent XSS is game-over — there's no hardware isolation on the web yet.
// React Native + expo-secure-store example
import * as SecureStore from "expo-secure-store";
const IDENTITY_KEY_STORAGE = "e2ee.identity";
export async function loadIdentityKey(): Promise<Uint8Array | null> {
const b64 = await SecureStore.getItemAsync(IDENTITY_KEY_STORAGE, {
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
requireAuthentication: true,
});
return b64 ? base64ToBytes(b64) : null;
}8. Step 5 — Multi-device Sync (the Hard Part)
Real users have a phone and a laptop. WhatsApp solves this with a message-level sender-keys variant: each device has its own session with each of the recipient's devices. The takeaway rules:
- Each device has its own identity key. The server publishes a "device list" per user.
- When Alice sends to Bob, she encrypts once per Bob device (parallel sessions).
- Alice's other devices are treated as separate recipients too — your own laptop is a potential attacker in this model.
- When a new device joins, the user scans a QR code (or compares a safety number) to verify the new device really belongs to them. This is the moment E2EE meets UX.
Skip multi-device for v1 if you can. It's the most common reason E2EE projects ship late.
9. Pitfalls That Break E2EE in Production
- Ordering bugs in X3DH/DH ratchet. Always run against the Signal test vectors.
- Logging plaintext or message keys. Treat message keys like credentials. Scrub them from memory with
sodium.memzeroafter use. - Reusing nonces. XChaCha20's 192-bit nonce makes this nearly impossible, but a "performance optimization" that caches nonces will re-break it.
- Server-side "convenience" features. Link previews, search indexing, content moderation — anything that requires the server to read the message breaks E2EE by definition. Move these to the client or to a user-held "secondary device."
- Backup that re-encrypts with a server key. If your "encrypted backup" is decryptable by you, it's decryptable by a subpoena. Give users a recovery passphrase they hold.
- Trust-on-first-use (TOFU) without verification. The first message can always be man-in-the-middle'd. Show a safety number and let users verify out-of-band.
- Plaintext metadata. E2EE only protects message contents. Who-talks-to-whom, timing, and message sizes still leak. If that matters, pad messages, mix in dummy traffic, and route metadata through a privacy proxy.
10. Testing and Verification
A few non-negotiable tests for any E2EE code:
describe("E2EE protocol", () => {
it("derives the same shared secret on both sides (X3DH)", () => {
// Alice and Bob run X3DH independently; root keys must match.
});
it("advances the ratchet symmetrically", () => {
// Send 100 messages; both sides derive the same message key each step.
});
it("survives out-of-order delivery up to the skipped-window", () => {
// Deliver messages 1,3,2,4 — receiver must decrypt all in order.
});
it("fails to decrypt after a tampered ciphertext byte", () => {
// Poly1305 tag must reject — no silent corruption.
});
it("passes the Signal test vectors for X3DH and Double Ratchet", () => {
// Pin to the reference test vectors.
});
});Add a continuous fuzzing job for the protocol layer. Crypto bugs are almost always state-machine bugs.
11. What to Build vs Buy
Building E2EE from scratch is a 3–6 month project for a senior team, and the failure modes are catastrophic. If you ship the wrong thing, you give users false confidence — which is worse than no encryption at all.
Reasonable choices before rolling your own:
- libsignal (Signal's own library): the canonical implementation. Available in TypeScript, Rust, Java, Swift.
- virgil-sdk / e3kit: higher-level wrapper around the same primitives, with a managed pre-key server.
- AWS Wickr: enterprise E2EE messaging SDK.
If you decide to build, run it past a third-party cryptographer before launch. A two-week audit is cheap insurance for a protocol that protects people's private lives.
Closing
End-to-end encryption is one of the few features where the engineering quality directly determines whether real people are safe. Get the threat model right, use vetted primitives, implement X3DH + Double Ratchet faithfully, store keys in hardware-backed storage, and test against the reference vectors. Then — and only then — talk to your users about it.
Building an E2EE-secured app?
We've shipped encrypted messaging and healthcare-grade secure systems for clients across India and North America. Tell us about your project — we'll share a clear path in 24 hours.
Building something in security?
Our QA & Testing team ships production-grade systems like this. Free 30-min consultation.
Subscribe
Get our best insights in your inbox
Practical guides on AI, cloud, security, and marketing. No spam, unsubscribe in one click.


