I’ve seen too many chat applications that promise privacy but don’t deliver. The code uses HTTPS and calls it a day, leaving every message sitting on a server, readable by anyone with access. This isn’t just a theoretical risk; it’s a design flaw that breaks user trust. I started looking into what it truly takes to build a system where only the intended recipient can read a message, and the path led directly to the protocols used by the most secure apps today. Let’s build something where privacy isn’t an afterthought.
The core idea is simple: messages should be encrypted on the sender’s device and only decrypted on the receiver’s device. The server in the middle should only see scrambled data. This is the only way to ensure that a data breach or a curious admin doesn’t expose private conversations. So, how do we get two devices that have never met to agree on a secret without anyone else listening in? This is the first major challenge.
We need a way to exchange keys securely. A common, but flawed, method is to have a user send their public key to the server, which then sends it to a friend. This is vulnerable to a person-in-the-middle attack. A better approach is the Extended Triple Diffie-Hellman (X3DH) key agreement protocol. It allows two parties to establish a shared secret even if one of them is offline when the conversation starts, using a combination of long-term and temporary keys.
Think of it like this. When you want to message someone, you first ask the server for their “prekey bundle.” This bundle contains a few different public keys: their long-term identity key, a medium-term signed prekey, and a single-use one-time prekey. You use all of these, combined with your own keys, to perform several Diffie-Hellman calculations. The result is a shared secret key that only you and your contact can compute.
But what happens if that secret key is compromised? An attacker could decrypt all future messages. To prevent this, we need “forward secrecy.” This means that even if a key is stolen today, past conversations remain safe, and future messages will quickly become secure again. This is achieved by constantly evolving the encryption keys.
This is where the Double Ratchet algorithm comes in. With every message sent, a “ratchet” mechanism creates a new encryption key. It’s called “double” because it uses two ratchets: one for deriving new keys from the shared secret (the root ratchet) and one for creating new sending/receiving keys for each message (the chain ratchet). This means each message is encrypted with a nearly unique key.
Let’s start with the foundation: our tools. We’ll use libsodium-wrappers, a reliable library that provides the cryptographic functions we need. The first step is always to wait for it to initialize. Here’s how we set up a secure utility module.
// crypto/sodium.js
const sodium = require('libsodium-wrappers');
let sodiumReady = false;
async function initializeSodium() {
if (!sodiumReady) {
await sodium.ready;
sodiumReady = true;
}
return sodium;
}
module.exports = { initializeSodium };
Before any chat can happen, users need cryptographic identities. Each user generates a long-term identity key pair. This is their unchanging crypto fingerprint. They also generate a set of temporary keys: signed prekeys and one-time prekeys, which are uploaded to the server to facilitate the initial handshake.
// crypto/keygen.js
const { initializeSodium } = require('./sodium');
async function generateIdentityKeyPair() {
const sodium = await initializeSodium();
const keypair = sodium.crypto_box_keypair();
return {
publicKey: sodium.to_base64(keypair.publicKey),
privateKey: sodium.to_base64(keypair.privateKey)
};
}
async function generatePreKeyBundle(identityKeyPair) {
const sodium = await initializeSodium();
// Generate a signed prekey
const signedPreKeyPair = sodium.crypto_box_keypair();
// Sign it with the identity private key
const signature = sodium.crypto_sign_detached(
signedPreKeyPair.publicKey,
sodium.from_base64(identityKeyPair.privateKey)
);
// Generate a one-time prekey
const oneTimePreKeyPair = sodium.crypto_box_keypair();
return {
identityKey: identityKeyPair.publicKey,
signedPreKey: sodium.to_base64(signedPreKeyPair.publicKey),
signedPreKeySignature: sodium.to_base64(signature),
oneTimePreKey: sodium.to_base64(oneTimePreKeyPair.publicKey),
// The private keys are stored locally, never sent to the server
};
}
module.exports = { generateIdentityKeyPair, generatePreKeyBundle };
Now, for the initial handshake using X3DH. When Alice wants to start a chat with Bob, her client fetches Bob’s prekey bundle from the server. Her client then performs multiple key exchanges to create a strong, shared secret. This process uses Bob’s public keys and Alice’s own keys, including a new, ephemeral key she generates just for this session.
The code to perform this calculation is intricate, but the logic follows the protocol’s steps: combine the results of several Diffie-Hellman operations and then hash them into a single, strong shared key. This key becomes the initial “root” for the Double Ratchet.
Once the initial secret is established, the Double Ratchet takes over. Each message triggers the creation of a new message key. The state—comprising the root key and the sending/receiving chain keys—is stored locally and must be synchronized perfectly between the two devices. If a message is lost, the ratchets will fall out of sync, requiring a new X3DH handshake.
Here is a simplified view of sending an encrypted message within an established session. The encryptMessage function would use the current sending chain key from the ratchet state.
// crypto/ratchet.js
async function encryptMessage(plaintext, ratchetState) {
const sodium = await initializeSodium();
// Derive a new message key from the current chain key
const output = sodium.crypto_kdf_derive_from_key(
32, // key length
ratchetState.messageNumber++, // subkey id
"msg", // context
ratchetState.chainKey
);
// The output contains new chain key and message key
ratchetState.chainKey = output.newChainKey;
const messageKey = output.messageKey;
// Encrypt using the message key (e.g., with XChaCha20-Poly1305)
const nonce = sodium.randombytes_buf(sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
const ciphertext = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(
plaintext,
null, // additional data
null,
nonce,
messageKey
);
// The message must include the header so the receiver can sync their ratchet
return {
header: ratchetState.currentHeader,
nonce: sodium.to_base64(nonce),
ciphertext: sodium.to_base64(ciphertext)
};
}
On the receiving side, the process is reversed. The client uses the header in the message to ensure it’s using the correct receiving chain, derives the same message key, and decrypts the text. If a message arrives out of order, the system must be able to store it and derive the correct key when it’s able to.
Building the chat system around this requires careful state management. Each conversation, or “session,” has its own ratchet state. This state is precious—if lost, the conversation cannot continue without a new handshake. You must store it securely on the client device, perhaps using encrypted local storage.
The server’s role changes dramatically. It no longer handles message content. Instead, it manages user registration, stores and serves prekey bundles, and routes encrypted message blobs from sender to receiver. A simple Socket.io server might listen for send_message events containing a recipient ID and a base64-encoded ciphertext package, then forward it.
// server.js (simplified routing)
io.on('connection', (socket) => {
socket.on('send_message', async (data) => {
// data: { to: 'userId', envelope: 'encryptedPackage' }
// Forward the encrypted envelope to the recipient's socket
io.to(data.to).emit('new_message', {
from: socket.userId,
envelope: data.envelope
});
});
});
This approach has trade-offs. You lose the ability for server-side search or analytics on message content. Key management becomes the user’s responsibility; if they lose their device and its keys, message history is gone. But these are the necessary costs of true privacy.
Why go through all this complexity? Because when you say “your messages are private,” you should mean it. Implementing this changes your perspective. You’re not just passing data; you’re building digital envelopes that only one person in the world can open. It’s challenging, but each part—key generation, the X3DH handshake, the rhythmic click of the ratchet—builds a system that respects user autonomy.
I hope this guide helps you move beyond basic authentication and into the world of real cryptographic security. Have you tried implementing a ratchet before? What was the biggest hurdle you faced? Share your thoughts and experiences below—let’s learn from each other. If you found this useful, please like and share it with other developers who care about building trustworthy software.
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