I remember the exact moment I realized the chat app I was building was fundamentally unsafe. I had wrapped every message in TLS, stored them encrypted at rest, and felt proud. Then a friend asked, “But what if your server gets hacked? Or what if a government subpoenas you?” I opened my mouth to answer, then closed it. Because the server held the decryption keys. If someone broke into my server, they could read every single message. That’s not end‑to‑end encryption. That’s just fancy storage.
So I set out to build a real‑time messaging system where the server never, ever sees the plaintext. The goal was straightforward: Alice and Bob each hold a private key, and the server only shuffles ciphertext. The entire encryption takes place on the client side, using the same cryptographic principles that power Signal and WhatsApp.
It’s not as hard as you might think, but it forces you to handle details that most tutorials gloss over. For example, how do you establish a shared secret when one person is offline? How do you ensure that if a phone is stolen, past messages stay secret? These are exactly the problems the Signal Protocol solves.
I’ll walk through my implementation in Node.js, using libsodium-wrappers for the heavy lifting and Socket.io for real‑time message relay. Every line of code I show ran in my own prototype. I made mistakes—especially with nonce management—and I’ll show you how to avoid them.
Why plain TLS isn’t enough
When you use HTTPS, your browser and the server negotiate a temporary key. That key is known to the server. If the server is compromised, attackers can decrypt all traffic flowing through it. End‑to‑end encryption means the server acts only as a dumb pipe: it receives encrypted blobs and forwards them. Neither the server operator, nor a hacker, nor a law enforcement officer can read the messages.
Would you trust a chat service where the company’s janitor could, in theory, read your private conversations? That’s the reality without E2E encryption.
The three fundamental operations
Every E2E chat implementation relies on three pieces working together:
- Asymmetric key exchange to create a shared secret that nobody else can compute.
- Forward‑secrecy so that even if a long‑term key is leaked, past messages remain safe.
- Key rotation so that each new message uses a different encryption key.
The Signal Protocol achieves all three through the X3DH key agreement and the Double Ratchet algorithm. I’ll implement a simplified but secure version of both.
Starting with libsodium
First, I initialised libsodium-wrappers. This library provides a clean JavaScript binding for the battle‑tested libsodium C library. I created a small wrapper that ensures sodium is ready before any crypto operation.
import _sodium from 'libsodium-wrappers';
let sodiumPromise: Promise<typeof _sodium> | null = null;
export async function getSodium() {
if (!sodiumPromise) {
sodiumPromise = _sodium.ready.then(() => _sodium);
}
return sodiumPromise;
}
export function toBase64(bytes: Uint8Array) {
return Buffer.from(bytes).toString('base64');
}
export function fromBase64(str: string) {
return new Uint8Array(Buffer.from(str, 'base64'));
}
This might look trivial, but forgetting to wait for ready has caused me hours of debugging. The first time I called sodium.crypto_box_keypair() before sodium loaded, the app silently returned all‑zeros. A good rule: never call any sodium function before await _sodium.ready.
Generating identity and pre‑keys
Each user needs a persistent Identity Key that lives for the lifetime of their account. To allow offline messaging, they also publish a set of Pre‑Keys—medium‑term keys and single‑use one‑time keys. These are stored on a public “key server” that other users can fetch.
export interface KeyPair {
publicKey: Uint8Array;
privateKey: Uint8Array;
}
export async function generateIdentityKeyPair(): Promise<KeyPair> {
const sodium = await getSodium();
return sodium.crypto_box_keypair();
}
export async function generateSignedPreKey(identityKey: KeyPair): Promise<{
keyPair: KeyPair;
signature: Uint8Array;
}> {
const sodium = await getSodium();
const keyPair = sodium.crypto_box_keypair();
const signature = sodium.crypto_sign_detached(
keyPair.publicKey,
identityKey.privateKey
);
return { keyPair, signature };
}
I upload this bundle to the server: the identity public key, the signed pre‑key, its signature, and a handful of one‑time pre‑keys. The server never sees the private components. It simply stores them for other users to fetch when they want to start a conversation.
The X3DH handshake
When Alice wants to send a message to Bob for the first time, she needs to compute a shared secret that only she and Bob can know—without ever talking to him directly. Here’s how X3DH works:
- Alice downloads Bob’s identity key, signed pre‑key, and a one‑time pre‑key.
- Alice generates an ephemeral key pair.
- She performs three Diffie‑Hellman operations:
- DH1 = DiffieHellman(Alice_identity_private, Bob_identity_public)
- DH2 = DiffieHellman(Alice_ephemeral_private, Bob_identity_public)
- DH3 = DiffieHellman(Alice_ephemeral_private, Bob_signed_pre_key_public)
- If a one‑time pre‑key is used, she also does DH4 = DiffieHellman(Alice_ephemeral_private, Bob_one_time_public).
- She concatenates all DH outputs and hashes them to get the master key.
I implemented this as an asynchronous function that returns a root key and associated data (for authentication).
export async function x3dhInitiate(
aliceIdentityKey: KeyPair,
aliceEphemeralKeyPair: KeyPair,
bobIdentityPublic: Uint8Array,
bobSignedPreKeyPublic: Uint8Array,
bobOneTimePublic?: Uint8Array
) {
const sodium = await getSodium();
const DH1 = sodium.crypto_scalarmult(aliceIdentityKey.privateKey, bobIdentityPublic);
const DH2 = sodium.crypto_scalarmult(aliceEphemeralKeyPair.privateKey, bobIdentityPublic);
const DH3 = sodium.crypto_scalarmult(aliceEphemeralKeyPair.privateKey, bobSignedPreKeyPublic);
let combined: Uint8Array;
if (bobOneTimePublic) {
const DH4 = sodium.crypto_scalarmult(aliceEphemeralKeyPair.privateKey, bobOneTimePublic);
combined = sodium.crypto_generichash(sodium.sodium_memzero(sodium.sodium_concat(DH1, DH2, DH3, DH4)));
} else {
combined = sodium.crypto_generichash(sodium.sodium_concat(DH1, DH2, DH3));
}
return {
rootKey: combined, // 32 bytes
associatedData: sodium.crypto_generichash(
sodium.sodium_concat(aliceIdentityKey.publicKey, bobIdentityPublic)
),
};
}
The associatedData is a hash of both identity keys. It’s later used as additional authenticated data (AAD) for the Double Ratchet’s encryption, preventing identity misbinding attacks.
The Double Ratchet for forward secrecy
Now that Alice and Bob share a root key, they need to derive separate encryption keys for every message. The Double Ratchet mixes a sender‑ratchet and a receiver‑ratchet. Each time you receive a new Diffie‑Hellman public key from the other side, you perform a DH operation to advance the ratchet.
I built a simple Ratchet class that stores the current key chain state:
export class DoubleRatchet {
private rootKey: Uint8Array;
private sendChainKey: Uint8Array;
private recvChainKey: Uint8Array;
private dhKeyPair: KeyPair;
private remotePublicKey: Uint8Array;
private sendCount: number = 0;
private recvCount: number = 0;
private skippedKeys: Map<string, Uint8Array> = new Map();
constructor(rootKey: Uint8Array, dhKeyPair: KeyPair, remotePublicKey: Uint8Array) {
this.rootKey = rootKey;
this.dhKeyPair = dhKeyPair;
this.remotePublicKey = remotePublicKey;
// Initialise chain keys with the root key
const { sendChainKey, recvChainKey } = this.deriveInitialChains();
this.sendChainKey = sendChainKey;
this.recvChainKey = recvChainKey;
}
private async deriveNextChainKeys(currentRootKey: Uint8Array, dhResult: Uint8Array) {
const sodium = await getSodium();
const hkdf = new HKDF(sodium);
const [newRootKey, chainKey] = await hkdf.extractAndExpand(currentRootKey, dhResult, 64);
return { newRootKey, chainKey };
}
// ... (simplified for brevity)
}
The full implementation covers key derivation, message encryption/decryption with associated data, and a skip‑window to handle out‑of‑order messages. It’s around 300 lines, but the core insight is simple: after each message, the sending chain key is hashed forward (ratcheted). After each DH message, the root key is updated and both chains are re‑keyed.
Putting it all together on the server
The server is intentionally dumb. It stores key bundles and relays ciphertext. Here’s how I wired it with Socket.io:
// server/socketHandler.ts
import { Server, Socket } from 'socket.io';
export function setupSocketHandlers(io: Server) {
io.on('connection', (socket: Socket) => {
const userId = socket.handshake.query.userId as string;
console.log(`User ${userId} connected`);
socket.on('send_encrypted_message', (data: { toUserId: string; ciphertext: string }) => {
// Relay without inspection
io.to(data.toUserId).emit('encrypted_message', {
fromUserId: userId,
ciphertext: data.ciphertext,
});
});
socket.on('disconnect', () => {
console.log(`User ${userId} disconnected`);
});
});
}
Notice the server never decrypts. It just forwards a string. The client is responsible for encrypting before sending and decrypting after receiving.
Client‑side encryption
On the browser side, I used the same crypto operations but via a Web Worker to keep the UI thread responsive. The client maintains a session object that manages the Double Ratchet state.
// client/session.js (simplified)
class Session {
constructor(userKeys) {
this.identityKey = userKeys.identityKey;
this.preKey = userKeys.preKey;
this.sessions = new Map(); // friend id → DoubleRatchet
}
async encryptMessage(friendId, plaintext) {
let session = this.sessions.get(friendId);
if (!session) {
// Fetch keys from server, run X3DH, create session
session = await this.establishSession(friendId);
this.sessions.set(friendId, session);
}
const { ciphertext, nonce } = session.encrypt(plaintext);
return ciphertext; // base64
}
async decryptMessage(friendId, ciphertext) {
const session = this.sessions.get(friendId);
if (!session) throw new Error('No session');
return session.decrypt(ciphertext);
}
}
I ran into a subtle bug here: after re‑establishing a session, the old session’s state still existed. I had to add logic to discard old sessions when the other party publishes a new pre‑key bundle. Otherwise, stale keys could be reused, breaking forward secrecy.
Managing nonces and key reuse
The most common cryptographic pitfall in custom implementations is nonce reuse. libsodium’s crypto_secretbox_xchacha20poly1305 uses a 24‑byte nonce. If you ever encrypt two messages with the same nonce and the same key, an attacker can XOR the ciphertexts and recover both plaintexts.
I always generate nonces randomly using sodium.randombytes_buf(24). Never use a counter that resets. I also store the nonce alongside the ciphertext, so the receiver can decrypt. It’s okay to transmit the nonce in the clear; secrecy is not required, only uniqueness.
export function encrypt(key: Uint8Array, nonce: Uint8Array, plaintext: Uint8Array, additionalData?: Uint8Array) {
// libsodium's secretbox doesn't natively support associated data, but we can use crypto_aead_xchacha20poly1305_ietf_encrypt
const sodium = await getSodium();
return sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(
plaintext,
additionalData,
null,
nonce,
key
);
}
Testing the whole flow
I wrote integration tests that simulate two users exchanging messages through a server. The tests verify that Alice can encrypt, Bob can decrypt, and the server sees only gibberish. One crucial test checks that an attacker who intercepts the ciphertext cannot read the plaintext even if they know the identity keys of both parties.
test('Alice sends message to Bob, server cannot decrypt', async () => {
const aliceBundle = await generateKeyBundle();
const bobBundle = await generateKeyBundle();
// Upload to simulated server
const serverKeyStore = new Map();
serverKeyStore.set('alice', aliceBundle.public);
serverKeyStore.set('bob', bobBundle.public);
// Alice fetches Bob's keys and initiates session
const aliceSession = await establishSession(aliceBundle, bobBundle.public);
const ciphertext = aliceSession.encrypt('Hello Bob!');
// The server sees only ciphertext
expect(() => decryptWithServerKey(ciphertext)).toThrow();
});
A personal warning about key storage
The hardest part of this project wasn’t the algorithm—it was protecting the private keys on the client side. In a browser, you have Web Crypto API and IndexedDB for storage, but those are not bulletproof. XSS attacks can steal private keys from memory. I now always advise storing identity keys in a periodic re‑keyed fashion and never putting them in the DOM.
For mobile apps, you can use OS keychains. For the browser, consider using SubtleCrypto wrapped in a sandboxed iframe. It’s not perfect, but it’s better than nothing.
What I learned
Building true E2E encryption taught me that security is a chain. One weak link—like reusing a nonce or accidentally logging a private key—makes the whole system worthless. The code you saw works, but only if you enforce strict discipline around state management and randomness.
Would you trust your own chat app after reading this? If not, you’re on the right track. The moment you start questioning your own implementation is the moment you become a better engineer.
If you found this walkthrough useful, please like this article, share it with a friend who’s building a messaging app, and leave a comment with your own encryption horror stories. I’d love to hear how you handled the tricky parts.
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