js

Build an End-to-End Encrypted Chat App with Node.js, Web Crypto API, and Socket.io

Learn how to build an end-to-end encrypted chat app with Node.js, Web Crypto API, and Socket.io using ECDH, HKDF, and AES-GCM.

Build an End-to-End Encrypted Chat App with Node.js, Web Crypto API, and Socket.io

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

Keywords: end-to-end encryption, Node.js chat app, Web Crypto API, Socket.io, AES-GCM



Similar Posts
Blog Image
Build Type-Safe APIs with tRPC, Prisma, and Next.js: Complete Developer Guide 2024

Learn to build type-safe APIs with tRPC, Prisma & Next.js. Complete guide covers setup, database design, advanced patterns & deployment strategies.

Blog Image
How to Simplify API Calls in Nuxt 3 Using Ky for Cleaner Code

Streamline your Nuxt 3 data fetching with Ky—centralized config, universal support, and cleaner error handling. Learn how to set it up now.

Blog Image
Complete Multi-Tenant SaaS Guide: NestJS, Prisma, PostgreSQL Row-Level Security from Setup to Production

Learn to build scalable multi-tenant SaaS apps with NestJS, Prisma & PostgreSQL RLS. Master tenant isolation, security & architecture. Start building now!

Blog Image
How to Build SAML-Based Single Sign-On (SSO) with Node.js and Passport

Learn how to implement secure SAML SSO in your Node.js app using Passport.js and enterprise identity providers like Okta.

Blog Image
Building Event-Driven Microservices with NestJS RabbitMQ and TypeScript Complete Guide

Learn to build scalable event-driven microservices using NestJS, RabbitMQ & TypeScript. Master sagas, error handling, monitoring & best practices for distributed systems.

Blog Image
How to Build Scalable Job Queues with BullMQ, Redis Cluster, and TypeScript

Learn to build reliable, distributed job queues using BullMQ, Redis Cluster, and TypeScript. Improve performance and scale with confidence.