I remember the first time I built a chat app. It worked. Messages flew from Alice to Bob via my server, and everyone was happy—until I realized that I, the server operator, could read every single message. That’s when I decided I would never build another chat app without end-to-end encryption. Because trust isn’t about promising you won’t peek. It’s about making it technically impossible for you to peek.
So here’s what I learned: The Signal Protocol is not magic. It’s a combination of two algorithms—X3DH for initial key exchange and the Double Ratchet for ongoing message encryption. And with libsodium, we can implement it in Node.js without writing a single cryptographic primitive from scratch.
But first, let me ask you a question: if you build a chat app, how do you guarantee that even if your database is stolen or your server is compromised, no one can read past messages? Think about it for a second. The answer lies in forward secrecy and break-in recovery.
The Core Idea: Your Server Should Be a Dumb Relay
In a traditional model, the server decrypts incoming messages, stores them in plaintext, then re-encrypts for the recipient. That’s what TLS protects: the wire. But the server itself is a giant target. End-to-end encryption flips the model: the server only sees encrypted blobs. It stores ciphertext and headers that are public (like the ratchet’s current key index), but it never holds the decryption keys.
I like to think of it as a mailman carrying sealed envelopes in a locked mailbox. The mailman can see the envelope, read the address, and even sort it, but he cannot open it. If the mailman is a bad actor, he still can’t read the letter. That’s the goal.
The Two-Phase Dance: X3DH + Double Ratchet
Imagine Alice wants to send her first message to Bob. They have never communicated before. How do they both end up with the same shared secret without ever exchanging one over the wire? The answer is the Extended Triple Diffie-Hellman (X3DH) key agreement.
Here’s the high-level flow:
- Bob uploads a bundle to the server: his identity key (a long-term X25519 keypair), a signed prekey (rotated weekly), and a pool of one-time prekeys.
- Alice fetches Bob’s bundle from the server.
- Alice mixes her identity key, her ephemeral key, Bob’s identity key, Bob’s signed prekey, and optionally one of Bob’s one-time prekeys. The output is a shared secret.
- Both now have the same initial key material, even though they never directly spoke.
- Alice destroys the one-time prekey on the server so no one else can use it.
Then, for every subsequent message, Alice and Bob use a Double Ratchet. Each message advances a “ratchet” that mixes new Diffie-Hellman outputs. This ensures that if a private key is ever leaked in 2025, messages from 2024 remain unreadable. That’s forward secrecy.
Why a “ratchet”? Because the ratchet only moves forward—it never goes back. Like a mechanical ratchet that clicks in one direction, you cannot turn it backwards. In code, that means each ratchet step replaces the old keys with new ones derived from a hash chain. The old keys are thrown away.
Let me show you how this looks in libsodium. First, install the wrapper:
npm install libsodium-wrappers
Now, generating an X25519 keypair:
import sodium from 'libsodium-wrappers';
async function generateIdentityKeypair() {
await sodium.ready;
const keypair = sodium.crypto_kx_keypair();
return {
publicKey: keypair.publicKey,
privateKey: keypair.privateKey
};
}
Notice I used crypto_kx_keypair? That’s the X25519 key exchange curve. It’s the same curve Signal uses.
The Prekey Bundle – Bob’s Public Roster
Bob needs to publish his public keys so Alice can perform X3DH. The server stores them in the database we designed earlier. But Bob never sends his private keys to the server. They stay in his browser’s local storage (or IndexedDB). That’s non-negotiable.
Here’s how Bob would upload his bundle:
// On Bob's client
async function createPrekeyBundle() {
await sodium.ready;
// Identity key (long-term)
const ik = sodium.crypto_kx_keypair();
// Signed prekey (medium-term)
const spk = sodium.crypto_kx_keypair();
const spkSignature = sodium.crypto_sign_detached(
spk.publicKey,
ik.privateKey
);
// One-time prekeys (multiple, say 50)
const otpks = Array.from({ length: 50 }, () => sodium.crypto_kx_keypair().publicKey);
// Send to server (over HTTPS)
await fetch('/api/keys/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
identityKey: sodium.to_base64(ik.publicKey),
signedPrekey: {
id: 1,
publicKey: sodium.to_base64(spk.publicKey),
signature: sodium.to_base64(spkSignature)
},
oneTimePrekeys: otpks.map((k, i) => ({ id: i, publicKey: sodium.to_base64(k) }))
})
});
// Store private keys locally
localStorage.setItem('ik_private', sodium.to_base64(ik.privateKey));
localStorage.setItem('spk_private', sodium.to_base64(spk.privateKey));
}
The server receives this and stores the public keys. Notice the signed prekey is signed by the identity key. That’s how Alice can verify it really belongs to Bob.
The X3DH Agreement: Alice Computes the Shared Secret
When Alice wants to start a conversation, she fetches Bob’s bundle:
const bundle = await fetch(`/api/keys/bundle?userId=${bobId}`).then(r => r.json());
Then she computes the initial shared secret. I’ll show you the four Diffie-Hellman operations that X3DH performs:
async function x3dhAlice(
aliceIK: { publicKey: Uint8Array; privateKey: Uint8Array }, // identity
aliceEK: { publicKey: Uint8Array; privateKey: Uint8Array }, // ephemeral (newly generated)
bobBundle: { identityKey: string; signedPrekey: string; otpk?: string }
) {
await sodium.ready;
const bobIK = sodium.from_base64(bobBundle.identityKey);
const bobSPK = sodium.from_base64(bobBundle.signedPrekey);
// DH1: Alice’s identity key × Bob’s identity key
const dh1 = sodium.crypto_generichash(32, sodium.crypto_scalarmult(aliceIK.privateKey, bobIK));
// DH2: Alice’s identity × Bob’s signed prekey
const dh2 = sodium.crypto_generichash(32, sodium.crypto_scalarmult(aliceIK.privateKey, bobSPK));
// DH3: Alice’s ephemeral × Bob’s identity
const dh3 = sodium.crypto_generichash(32, sodium.crypto_scalarmult(aliceEK.privateKey, bobIK));
// DH4: Alice’s ephemeral × Bob’s signed prekey
const dh4 = sodium.crypto_generichash(32, sodium.crypto_scalarmult(aliceEK.privateKey, bobSPK));
// If a one-time prekey exists, do one more DH with it.
let dh5;
if (bobBundle.otpk) {
const bobOTPK = sodium.from_base64(bobBundle.otpk);
dh5 = sodium.crypto_generichash(32, sodium.crypto_scalarmult(aliceEK.privateKey, bobOTPK));
}
// Combine all DH outputs into a single secret
const secret = sodium.crypto_generichash(32,
Buffer.concat([dh1, dh2, dh3, dh4, dh5 || new Uint8Array(32)])
);
return secret; // This is SK (initial shared root key)
}
Now Alice has the initial root key. She also knows which prekey she used (the signed prekey’s ID and the one-time prekey ID if used). Bob, when he receives Alice’s first message, will perform the same computation on his side using his private keys to derive the same secret.
I can already hear you asking: “Bob wasn’t online when Alice computed this. How does he get the same secret?” Good question. Bob’s private keys are on his device. When Bob comes online and receives Alice’s first message (which includes her identity key, ephemeral key, prekey IDs), he repeats the four DH operations using his private keys. The math guarantees both arrive at the same 32‑byte root key.
The Double Ratchet: Encrypting Individual Messages
Now that Alice and Bob share a root key (SK), they can start ratcheting. The Double Ratchet has two parts:
- Sending Ratchet – A chain of symmetric keys (derived from a root key and a Diffie-Hellman output) that are used to encrypt outgoing messages.
- Receiving Ratchet – The symmetric chain that decrypts incoming messages.
The cool part: each time either side sends or receives a message, the ratchet can be “advanced.” Every few messages, you perform a new Diffie-Hellman exchange to generate a new root key, providing forward secrecy.
Here’s a simplified version of the sending ratchet:
class DoubleRatchet {
private rootKey: Uint8Array;
private sendChainKey: Uint8Array; // current chain key for sending
private receiveChainKey: Uint8Array;
private sendCounter: number = 0;
private receivedCounter: number = 0;
private dhRatchetPrivateKey: Uint8Array; // our current DH keypair private key
private dhRatchetPublicKey: Uint8Array; // our current DH public key
constructor(sharedSecret: Uint8Array, bobPublicKey: Uint8Array) {
this.rootKey = sharedSecret;
// First ratchet: generate a new DH keypair and compute new root
const newDH = sodium.crypto_kx_keypair();
this.dhRatchetPrivateKey = newDH.privateKey;
this.dhRatchetPublicKey = newDH.publicKey;
this.sendChainKey = this.kdfRoot(newDH.publicKey, bobPublicKey); // simplified
}
private kdfRoot(dhOutput: Uint8Array, base: Uint8Array): Uint8Array {
// In practice uses HKDF; here we just hash
return sodium.crypto_generichash(32, Buffer.concat([this.rootKey, dhOutput, base]));
}
encrypt(plaintext: string): { ciphertext: Uint8Array; header: { publicKey: Uint8Array; counter: number } } {
const msgKey = this.deriveMessageKey(this.sendChainKey);
this.sendChainKey = this.ratchetChain(this.sendChainKey); // advance chain
const nonce = new Uint8Array(24); // XChaCha20 uses 24-byte nonce
const ciphertext = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(
plaintext,
null, // no AD
null,
nonce,
msgKey
);
return { ciphertext, header: { publicKey: this.dhRatchetPublicKey, counter: this.sendCounter++ } };
}
// ... decrypt, ratchet advancement omitted for brevity
}
The key point: every encryption step consumes one chain key and produces the next. The counter is included in the message header so the receiving side knows which chain key to use. And after a predefined number of messages (or when the other party sends new DH public key), you perform a dhRatchet step that mixes new Diffie-Hellman output into the root key.
Storing and Relaying Messages: The Server’s Innocent Job
When Alice sends an encrypted message, her client builds the header (containing her current public key and the message number) and the ciphertext. Then she sends both to the server via Socket.io:
socket.emit('send_direct_message', {
recipientId: bobId,
header: {
pubkey: sodium.to_base64(header.publicKey),
counter: header.counter
},
ciphertext: sodium.to_base64(ciphertext)
});
The server receives this, stores it in the messages table (with header as JSONB, ciphertext as BYTEA), and forwards it to Bob if he’s online. The server never attempts to decrypt. It’s just a post office.
When Bob comes online later, he fetches missed messages:
const response = await fetch(`/api/messages/unread`);
const msgs = await response.json();
for (const msg of msgs) {
// Bob uses his local private keys + header to recompute chain and decrypt
const plaintext = bobRatchet.decrypt(
sodium.from_base64(msg.ciphertext),
{ publicKey: sodium.from_base64(msg.header.pubkey), counter: msg.header.counter }
);
console.log(plaintext);
}
The Hard Parts I Ignored (and Why They Matter)
This implementation is a skeleton. Real Signal Protocol includes:
- Key rotation: One-time prekeys are consumed; signed prekeys must be re-generated weekly.
- Session management: Each conversation is a separate session with its own ratchet state.
- Out-of-order messages: The receiving chain must store skipped keys to handle messages that arrive later.
- Replay protection: Use a separate chain for each message and never reuse nonces.
But even this skeleton gives you a working system where the server is blind. The only way an attacker can read messages is to compromise both Alice’s and Bob’s devices at the same time during an active conversation.
One Last Question Before We Finish
If you build a chat app today, and you don’t encrypt messages end-to-end, what stops a rogue employee from reading every love letter, business deal, or secret your users exchange? Nothing. E2E encryption isn’t a feature—it’s a responsibility.
I hope this walkthrough gives you the confidence to implement your own. Start small: get two clients exchanging encrypted messages through a dumb server. Then add the bells and whistles. You’ll be amazed at how liberating it feels to no longer be responsible for your users’ secrets.
If this article helped you understand the mechanics of E2E encryption, hit like. If you have a question about a specific detail (like how to handle signed prekey signatures or what to do when a one-time prekey is missing), drop it in the comments. And share this with a developer friend who insists that “just TLS is enough” – they might thank you later.
As a best-selling author, I invite you to explore my books on Amazon. Don’t forget to follow me on Medium and show your support. Thank you! Your support means the world!
101 Books
101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.
Check out our book Golang Clean Code available on Amazon.
Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!
📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!
Our Creations
Be sure to check out our creations:
Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools
We are on Medium
Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva