I’ve always been fascinated by how Signal and WhatsApp manage to keep messages private. The idea that even the server can’t read your messages seemed like magic. So I decided to build my own end-to-end encrypted chat system using Node.js, the Web Crypto API, and Socket.io. Let me walk you through it.
The core problem is simple: Alice wants to send a private message to Bob. They both trust the server to relay messages, but they don’t want the server to understand the content. The solution is a hybrid cryptosystem. First, Alice and Bob exchange public keys. Then they each derive a shared secret using Elliptic Curve Diffie-Hellman (ECDH). Finally, they encrypt messages with AES-GCM using that shared secret. The server only sees encrypted ciphertext and public keys.
How do two parties agree on a secret key without the server learning it? That’s where ECDH works like a charm. Each user generates a key pair — private and public. Alice sends her public key to Bob via the server. Bob does the same. Now, Alice takes her private key and Bob’s public key, runs the ECDH algorithm, and gets a shared secret. Bob does the same with his private key and Alice’s public key. The math ensures both arrive at the identical secret. The server never sees private keys.
But a shared secret isn’t directly an encryption key. You need to derive a proper symmetric key from it. I use HKDF (HMAC-based Key Derivation Function) to turn the shared secret into a 256-bit AES key. This is a common pattern used in Signal and TLS 1.3.
Now for encryption. AES-GCM provides both confidentiality and integrity. Without integrity, an attacker could tamper with ciphertext. GCM includes an authentication tag that detects any modification. I generate a random 12-byte initialization vector (IV) for each message. Never reuse an IV with the same key. That’s a fatal mistake.
Let’s look at the server code. It’s intentionally dumb.
// server/src/index.ts
import express from 'express';
import { createServer } from 'http';
import { Server } from 'socket.io';
import { KeyRegistry } from './keyRegistry';
const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, { cors: { origin: '*' } });
const registry = new KeyRegistry();
io.on('connection', (socket) => {
console.log(`User connected: ${socket.id}`);
socket.on('user:register-key', (payload) => {
registry.storePublicKey(payload.userId, payload.publicKeyJwk);
registry.mapSocket(payload.userId, socket.id);
socket.broadcast.emit('user:joined', payload.userId);
});
socket.on('user:request-key', (targetUserId) => {
const key = registry.getPublicKey(targetUserId);
if (key) socket.emit('user:public-key', key);
});
socket.on('message:send', (encryptedMsg) => {
const targetSocketId = registry.getSocketId(encryptedMsg.toUserId);
if (targetSocketId) {
io.to(targetSocketId).emit('message:receive', encryptedMsg);
}
});
socket.on('disconnect', () => {
const userId = registry.getUserIdBySocket(socket.id);
if (userId) {
registry.removeUser(userId);
socket.broadcast.emit('user:left', userId);
}
});
});
httpServer.listen(3000, () => console.log('Server running on port 3000'));
Notice the server never decrypts anything. It just routes encrypted blobs. The key registry stores only public keys in JWK format. In production, you would use a database and add authentication.
Now the client side — where all the cryptographic work happens. I use the Web Crypto API available in both browsers and Node.js 18+. The following functions live in a single module.
// client/src/crypto.ts
export async function generateKeyPair(): Promise<CryptoKeyPair> {
return crypto.subtle.generateKey(
{ name: 'ECDH', namedCurve: 'P-256' },
false, // non-exportable private key
['deriveKey', 'deriveBits']
);
}
export async function deriveSharedSecret(
privateKey: CryptoKey,
publicKey: CryptoKey
): Promise<ArrayBuffer> {
return crypto.subtle.deriveBits(
{ name: 'ECDH', public: publicKey },
privateKey,
256
);
}
export async function deriveAESKey(sharedSecret: ArrayBuffer): Promise<CryptoKey> {
return crypto.subtle.importKey(
'raw',
await crypto.subtle.digest('SHA-256', sharedSecret),
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}
export async function encryptMessage(
aesKey: CryptoKey,
plaintext: string
): Promise<{ ciphertext: ArrayBuffer; iv: Uint8Array }> {
const iv = crypto.getRandomValues(new Uint8Array(12));
const encoded = new TextEncoder().encode(plaintext);
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
aesKey,
encoded
);
return { ciphertext, iv };
}
export async function decryptMessage(
aesKey: CryptoKey,
ciphertext: ArrayBuffer,
iv: Uint8Array
): Promise<string> {
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
aesKey,
ciphertext
);
return new TextDecoder().decode(decrypted);
}
When Alice wants to send a message, she first requests Bob’s public key from the server. Then she derives the shared secret, derives the AES key, encrypts the message, and sends the ciphertext and IV to the server. Bob, upon receiving the encrypted message, uses his stored private key and Alice’s public key (which he already has) to derive the same shared secret and AES key, then decrypts.
A personal touch: when I first implemented this, I forgot to handle the case where a user comes online after the other has left. So now I store public keys persistently and allow offline key retrieval. The server can serve public keys on demand.
What about forward secrecy? In my current design, if a private key leaks, all past messages can be decrypted because the AES key is derived from the same ECDH shared secret every time. To achieve perfect forward secrecy, you need to rotate keys for each session — like Signal’s Double Ratchet algorithm. That’s the next level of complexity.
But as a starting point, this system works for a closed group where key compromise is unlikely. You can add mechanisms like session rekeying later.
One crucial security measure: validate public keys. The server should verify that a user’s public key hasn’t been tampered with. For a true E2EE setup, users should verify key fingerprints out-of-band (as Signal does with safety numbers). I’ve omitted that here for brevity, but never skip it in production.
Now I want you to take a moment and think: in a real deployment, how would you handle malicious users who send malformed ciphertext or reuse IVs? AES-GCM’s authentication tag catches tampering, but you need to check on the client side. If decryption fails, you should discard the message and optionally notify the sender.
My implementation handles decryption errors gracefully:
export async function safeDecrypt(
aesKey: CryptoKey,
ciphertext: ArrayBuffer,
iv: Uint8Array
): Promise<string | null> {
try {
return await decryptMessage(aesKey, ciphertext, iv);
} catch (e) {
console.error('Decryption failed — message possibly tampered.');
return null;
}
}
The user experience should never reveal whether decryption failed due to a wrong key or tampering. Keep that information hidden to avoid oracle attacks.
Alright, let’s connect the client to the server. I use a simple Socket.io wrapper:
// client/src/socket.ts
import { io, Socket } from 'socket.io-client';
export function createE2EESocket(userId: string, publicKeyJwk: JsonWebKey): Socket {
const socket = io('http://localhost:3000');
socket.emit('user:register-key', { userId, publicKeyJwk });
return socket;
}
And the app logic in React or vanilla JS listens for ‘message:receive’, decrypts, and displays. The complete flow from sending a message: serialize plaintext → encrypt → base64 encode (since Socket.io prefers strings) → emit to server. On receive: base64 decode → decrypt.
I’ve built this exact system for a side project where I wanted to send private notes between devices. The feeling of seeing “decrypted” content after the server forwarded it is incredibly satisfying. It’s the closest I’ve come to feeling like I’m building real privacy.
Now, one thing I love about the Web Crypto API is that it’s standard across browsers and Node.js. No need for OpenSSL bindings or external libraries. But if you need faster performance or ChaCha20-Poly1305, consider using libsodium or tweetnacl. The trade-off is portability.
Let me leave you with a final thought: building your own E2EE system is educational, but for production, always rely on peer-reviewed libraries and protocols. My code here is a starting point, not a finished product.
If this article helped you understand the mechanics of end-to-end encryption, please like, share, and comment below. I’d love to hear about your own experiments with cryptography and real-time systems. Have you ever implemented a double ratchet? What challenges did you face? Let’s discuss.
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