I’ve been building chat applications for years. Early on, I thought slapping HTTPS on everything made them secure. Then I read about how WhatsApp and Signal actually hide messages even from their own servers. That changed everything. I realized real privacy means only the sender and receiver can read the message—not the server, not the internet provider, not even the government with a warrant. So I started learning about the Signal Protocol. It’s what powers the most private messaging apps in the world. In this article, I’ll walk you through how I implemented end‑to‑end encryption in a Node.js chat app using the same cryptographic primitives—libsodium and the double ratchet algorithm. I’ll show you the code, the pitfalls, and the mental model that makes it all work.
When you send a message over a chat app, where does it go? The server receives it, stores it, and forwards it. If the server is compromised, anyone can read every word. That’s what end‑to‑end encryption (E2EE) prevents: the server becomes a blind relay. It handles routing but cannot decrypt the payload. For that, each pair of users must agree on a shared secret that only they know, and then derive new keys for every single message. This combination of initial key agreement and per‑message key rotation is the heart of the Signal Protocol.
Let me show you how I generated the key bundles. Every user publishes a public set of keys to a central server: a long‑term identity key, a signed pre‑key, and a handful of one‑time pre‑keys. The server stores these, but it never sees the corresponding private keys. Those stay on the user’s device. I used libsodium for all cryptographic operations. Here’s how I generate a signed pre‑key and sign it with the identity key:
import sodium from 'libsodium-wrappers';
await sodium.ready;
const identityKeyPair = sodium.crypto_sign_keypair(); // Ed25519
const signedPreKeyPair = sodium.crypto_kx_keypair(); // X25519
const signature = sodium.crypto_sign_detached(
signedPreKeyPair.publicKey,
identityKeyPair.privateKey
);
Notice I sign the public key of the signed pre‑key with the long‑term identity key. This allows anyone who receives this pre‑key to verify that it actually belongs to me, not an attacker. The server cannot forge this signature because it doesn’t have my private identity key.
Now, how does Alice start a conversation with Bob? She needs to compute a shared secret using Bob’s published keys. This is the X3DH step, or extended triple Diffie‑Hellman. She grabs Bob’s identity key, his signed pre‑key, and optionally one of his one‑time pre‑keys. Then she performs three DH exchanges and combines them into a single root key. The server never knows this root key. When I implemented this, I used libsodium’s crypto_kx functions, but for X3DH you need to do manual DH with curve25519. Here’s the core of the computation:
// Alice knows Bob's identity key (IK_pub), signed pre‑key (SPK_pub)
// and has her own identity key (IK_priv) and an ephemeral key (EK_priv)
const dh1 = sodium.crypto_scalarmult(IK_priv, SPK_pub);
const dh2 = sodium.crypto_scalarmult(EK_priv, IK_pub);
const dh3 = sodium.crypto_scalarmult(EK_priv, SPK_pub);
const sharedSecret = sodium.crypto_generichash(
32, sodium.merge_arrays([dh1, dh2, dh3, some_associated_data])
);
That sharedSecret is the root key. But we’re not done yet—if we reuse this same key for every message, once an attacker steals it, they can decrypt the entire conversation. That’s why the Signal Protocol uses a double ratchet to rotate the encryption key for every single message.
What does “double ratchet” mean? Think of two clocks ticking: one forwards based on sending messages, the other forwards based on receiving messages. Every time I send a message, I combine the current sending key with a new ephemeral public key and hash both to produce the next sending key. My message carries that ephemeral public key. When Bob receives it, he uses his own private key along with my public key to derive the same new sending key. This gives us forward secrecy: even if an attacker gets our private keys later, they cannot decrypt past messages because those keys were already destroyed.
Here’s a simplified version of ratchet step I used for sending a message:
function ratchetSend(currentKey: Uint8Array) {
const ephKeyPair = sodium.crypto_kx_keypair();
const shared = sodium.crypto_scalarmult(currentKey, ephKeyPair.publicKey);
const nextKey = sodium.crypto_generichash(32, shared);
const messageKey = sodium.crypto_generichash(32, nextKey); // for encryption
return { nextKey, messageKey, ephPub: ephKeyPair.publicKey };
}
I store the nextKey and never use the messageKey again. Every message gets a fresh key. If you think about it, this drastically limits the damage of a key leak—only one message is exposed, not the entire history.
Now, what about hiding the sender’s identity from the server? That’s the “sealed sender” pattern. Normally the server knows who sent a message to whom. With sealed sender, the message envelope is encrypted to the recipient’s public key, and the sender’s identity is encrypted inside. The server can only see a random public key (the sender’s ephemeral key) and a ciphertext it cannot open. How can the server know where to deliver it? It stores a mapping (like a mailbox ID) tied to the recipient’s public key bundle. Here’s a rough implementation:
// Sender encrypts message and their ephemeral identity
const sealedMessage = {
ephemeralKey: senderEphPub,
ciphertext: sodium.crypto_box_easy(
JSON.stringify({ sender, text }),
nonce,
recipientPubKey,
senderEphPriv
)
};
// The client posts this to a relay endpoint, which sends it to the recipient's inbox
The relay server only routes based on the recipient’s ID, not the sender. It cannot read the sender because that’s inside the encrypted box.
One trap I fell into early on was using the same nonce twice with the same key. Libsodium’s crypto_box requires a unique nonce per message. I generate one using sodium.randombytes_buf(24) and prepend it to the ciphertext. The receiver reads the nonce and decrypts.
Another common mistake is forgetting to verify the signature on the signed pre‑key. Without that check, an attacker could inject a fake pre‑key and perform a man‑in‑the‑middle attack. I always verify:
const valid = sodium.crypto_sign_verify_detached(signature, signedPreKeyPub, identityKeyPub);
Only proceed if valid.
Now, I must admit: building all this from scratch gave me a deep respect for the people who designed the Signal Protocol. It’s not just about using libsodium correctly—it’s about the protocol flow, the key lifecycle, and the careful state management for each conversation. I stored the double ratchet state per conversation in memory (or Redis with client‑side encryption) and purged it when no longer needed.
If you ever try this yourself, start small. First get the key generation and X3DH working. Test that both sides compute the same shared secret. Then add the ratchet. Then add sealed sender. Each layer adds complexity, but also privacy.
I often ask myself: would I trust my own code to protect a conversation I care about? After testing with known‑answer tests and simulating key compromises, I slowly gained confidence. But nothing beats using an existing, audited library. The code I’ve shown you is for learning. For production, wrap your head around the patterns and then use a library like libsignal-protocol or signal-protocol-javascript. That said, understanding the underlying math empowers you to make better security decisions.
If this article made you think differently about chat security, or if you have questions about the implementation, please like, share, and comment below. I want to hear about your own crypto adventures, especially the mistakes you avoided (or fell into). Let’s keep the conversation safe.
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