js

Build an End-to-End Encrypted Chat App in Node.js with Signal Protocol Concepts

Learn to build a private Node.js chat app with end-to-end encryption, X3DH, and double ratchet concepts. Start coding secure messaging today.

Build an End-to-End Encrypted Chat App in Node.js with Signal Protocol Concepts

I’ve been thinking a lot about digital conversations lately. Every day, we send messages filled with personal thoughts, business plans, and private moments. We trust the apps we use, but have you ever stopped to wonder who else can read those words? A few months ago, a friend asked me to help build a simple chat tool for their small team. They had one non-negotiable rule: absolute privacy. The server should never, under any circumstances, be able to read the messages. That request sent me down a path to understand real end-to-end encryption. It’s not just a checkbox for “secure”; it’s a complete rethinking of how data flows. Today, I want to walk you through building this yourself. We’ll create a Node.js chat application where the server is just a messenger, blindly passing along gibberish it cannot understand. Let’s build something where privacy isn’t a feature, but the foundation.

You might be using apps right now that claim to have end-to-end encryption. But what does that actually mean? In most web applications, security means TLS—the padlock in your browser. This secures the pipe between you and the server. However, the server itself holds the keys to decrypt everything. End-to-end encryption is different. It ensures that data is encrypted on the sender’s device and only decrypted on the recipient’s device. The server in the middle handles encrypted data but lacks the ability to decipher it. This model protects you from more threats, including a breached server or a curious insider.

So, how do we build this? We can’t just encrypt a message with a static password. We need a system that establishes a shared secret between two people who have never met online, handles lost messages, and updates keys constantly. This is where the Signal Protocol comes in. It’s the engine behind apps like WhatsApp and Signal. It solves these problems elegantly, and we can implement its core ideas using tools already in your Node.js installation.

Getting started requires a clear picture. We’ll need two main parts: a key server and a chat server. The key server helps users exchange public keys securely when they first connect. The chat server is a simple WebSocket relay that passes encrypted blobs between clients. The real magic happens on the client side, where all the encryption and decryption occurs. I’ll share the code as we go, so you can follow along.

First, let’s set up our project environment. Create a new directory and initialize a Node.js project. We’ll use TypeScript for clarity and safety. Here are the essential packages.

npm init -y
npm install ws express uuid
npm install -D typescript ts-node @types/ws @types/node @types/express @types/uuid

Next, configure TypeScript. Create a tsconfig.json file in your project root.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "strict": true,
    "esModuleInterop": true,
    "outDir": "./dist"
  }
}

With the setup ready, we face our first big question: how do two strangers on the internet agree on a secret key without anyone else finding out? Think about it. You need to start a secure chat with someone whose device might be offline. The Signal Protocol uses a method called X3DH—the Extended Triple Diffie-Hellman handshake. It sounds complex, but let’s break it down.

Every user generates a set of keys. There’s a long-term identity key, a medium-term signed pre-key, and a batch of one-time pre-keys. These are uploaded to the key server. When you want to message someone, you fetch their bundle of public keys. Your client then performs a series of cryptographic calculations using their keys and yours to derive a shared secret. This happens without the other person being online at that exact moment.

Let’s look at how we define these key bundles in code. We’ll create a shared type definition file.

// types.ts
export interface KeyBundle {
  userId: string;
  identityKey: JsonWebKey;
  signedPreKey: {
    keyId: number;
    publicKey: JsonWebKey;
    signature: string;
  };
  oneTimePreKeys: Array<{
    keyId: number;
    publicKey: JsonWebKey;
  }>;
}

But how do we know these keys are genuine and not placed there by an attacker? This is where digital signatures come in. The signed pre-key is stamped with a signature from the user’s long-term identity key. Your client can verify this signature, ensuring the key bundle hasn’t been tampered with. It’s a crucial step that often gets overlooked in simpler systems.

Now, we need to generate these keys. Node.js has a built-in Web Crypto API, so we don’t need extra libraries for basic operations. Here’s a function to generate an elliptic curve key pair, perfect for the identity key.

async function generateIdentityKeyPair(): Promise<CryptoKeyPair> {
  return await crypto.subtle.generateKey(
    {
      name: "ECDH",
      namedCurve: "P-256",
    },
    true,
    ["deriveKey"]
  );
}

I remember when I first used this API, I was surprised at how straightforward it was. The hard part isn’t the cryptography itself, but managing the keys and state securely. Where do you store the private keys? In a real application, you’d use secure storage like the system keychain. For our tutorial, we’ll keep them in memory, but never, ever send them over the network.

Once we have the keys, the X3DH handshake calculates a shared secret. This involves multiple Diffie-Hellman exchanges. The output is a root key that only the two communicating parties know. But what if someone intercepts the initial messages? The beauty of this handshake is that it provides “forward secrecy” for the initial setup. Even if the long-term keys are compromised later, the initial shared secret remains safe.

After the handshake, we need to encrypt actual messages. And this is where things get interesting. We can’t use the same key for every message; that would be a disaster. If one key is cracked, all messages are exposed. The Signal Protocol uses a “double ratchet” algorithm. It’s like a combination lock that turns with every message, constantly changing the key.

Imagine sending a message and then immediately deriving a new key for the next one. The double ratchet does this in two ways. First, a “root chain” ratchets forward with every exchanged message. Second, a “sending chain” generates a new key for each message sent. This means each message is encrypted with a unique key. If a single key is exposed, only that one message is at risk, not the entire conversation.

Let’s implement a simple version of the ratchet. We’ll need to maintain state for both the sender and receiver.

interface RatchetState {
  rootKey: CryptoKey;
  chainKey: CryptoKey;
  publicKey: CryptoKey;
  privateKey: CryptoKey;
}

async function ratchetStep(state: RatchetState, incomingPublicKey?: CryptoKey): Promise<{ newState: RatchetState; messageKey: CryptoKey }> {
  // Cryptographic magic happens here to derive new keys
  // This is a simplified placeholder for the actual KDF logic
  const newChainKey = await crypto.subtle.deriveKey(
    /* derivation parameters using HKDF */
    { name: "HKDF" },
    state.rootKey,
    { name: "AES-GCM", length: 256 },
    true,
    ["encrypt", "decrypt"]
  );
  // Update state and return a message key
  return { newState: { ...state, chainKey: newChainKey }, messageKey: newChainKey };
}

This is a complex piece, and it’s okay if it doesn’t click immediately. The key takeaway is that the encryption key evolves. But here’s a question to ponder: how does the receiver stay in sync if messages arrive out of order? The protocol handles this by including metadata like message numbers, allowing the receiver to process them correctly.

Now, let’s bring this to life with a chat system. We’ll create a WebSocket server in Node.js that acts as a dumb relay. It doesn’t care about the content; it just passes JSON objects between clients. The client will handle all the encryption and decryption.

First, the key server. It’s a simple HTTP server with endpoints to upload and fetch key bundles. Here’s a snippet for the fetch endpoint.

import express from 'express';
const app = express();
app.use(express.json());

const keyStore = new Map(); // In-memory store for demo

app.get('/bundle/:userId', (req, res) => {
  const bundle = keyStore.get(req.params.userId);
  if (!bundle) return res.status(404).send('User not found');
  res.json(bundle);
});

This server is trivial, but in production, you’d add authentication, persistence, and more security layers. The important part is that it only deals with public keys, never private ones.

Next, the WebSocket chat server. It listens for connections and routes messages based on recipient IDs.

import WebSocket, { WebSocketServer } from 'ws';

const wss = new WebSocketServer({ port: 8080 });
const clients = new Map();

wss.on('connection', (ws, req) => {
  const userId = new URL(req.url, `http://${req.headers.host}`).searchParams.get('userId');
  if (!userId) { ws.close(); return; }
  clients.set(userId, ws);
  
  ws.on('message', (data) => {
    const message = JSON.parse(data.toString());
    const recipientWs = clients.get(message.recipientId);
    if (recipientWs) recipientWs.send(JSON.stringify(message));
  });
});

This server is unaware of the message content. It sees encrypted ciphertext, which looks like random data. This is the goal—the server is just a postman.

On the client side, we need to orchestrate everything. When Alice wants to chat with Bob, her client fetches Bob’s key bundle, performs X3DH, and sends an initial encrypted message. From then on, the double ratchet takes over. Each message includes a new ratchet public key so the other side can derive the next keys.

Encrypting a message involves using the current message key with AES-GCM, which provides both confidentiality and integrity. Here’s a simplified encrypt function.

async function encryptMessage(message: string, key: CryptoKey): Promise<{ ciphertext: string; iv: string; }> {
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const encodedMessage = new TextEncoder().encode(message);
  const ciphertext = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv },
    key,
    encodedMessage
  );
  return {
    ciphertext: Buffer.from(ciphertext).toString('base64'),
    iv: Buffer.from(iv).toString('base64')
  };
}

Notice that we generate a new random initialization vector (IV) for every message. This is critical for security. But what stops someone from replaying an old message? We include a timestamp and a message ID, and the receiver can reject duplicates. The HMAC in the message format helps verify authenticity.

Putting it all together, the client manages sessions. For each contact, it stores the current ratchet state, sequence numbers, and keys. When a message arrives, it uses the included ratchet public key to update its receiving chain and decrypt the content. This dance happens seamlessly in the background.

Testing this system is vital. You need to simulate network delays, lost messages, and malicious inputs. I often write unit tests for each cryptographic function and then integration tests for the entire flow. A common pitfall is forgetting to handle the case when one-time pre-keys run out. The protocol accounts for this by allowing the recipient to upload new batches.

As we build, remember that security is subtle. A tiny mistake, like reusing an IV, can break everything. Always use established libraries for production, but understanding the internals, as we do here, makes you a better developer. It empowers you to audit the code you depend on.

Now, I’m curious—what do you think is the hardest part of deploying such a system at scale? Key management and user experience often become the real challenges, not the cryptography itself.

In closing, building end-to-end encryption is a rewarding journey into the heart of digital trust. We’ve covered the why and the how, from key exchange to message ratcheting. This knowledge lets you create applications that respect user privacy by design. I encourage you to take this foundation, experiment with the code, and see how it works firsthand. Share your creations, ask questions, and let’s keep the conversation going. If you found this guide helpful, please like, share, and comment with your thoughts or experiences. Your feedback helps others learn and improves the content for everyone. Let’s build a more secure web, one line of code at a time.


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, Signal Protocol, X3DH, double ratchet



Similar Posts
Blog Image
Distributed Rate Limiting with Redis and Node.js: Complete Implementation Guide

Learn how to build scalable distributed rate limiting with Redis and Node.js. Complete guide covering Token Bucket, Sliding Window algorithms, Express middleware, and monitoring techniques.

Blog Image
Build Complete Event-Driven Microservices Architecture with NestJS, RabbitMQ, MongoDB: Step-by-Step Tutorial

Learn to build event-driven microservices with NestJS, RabbitMQ & MongoDB. Master saga patterns, error handling, monitoring & deployment for scalable systems.

Blog Image
How to Integrate Prisma with GraphQL: Complete Type-Safe Backend Development Guide 2024

Learn how to integrate Prisma with GraphQL for type-safe database access and efficient API development. Build scalable backends with reduced boilerplate code.

Blog Image
Build Real-Time Web Apps: Complete Svelte and Supabase Integration Guide for Modern Developers

Build real-time web apps with Svelte and Supabase integration. Learn to combine reactive frontend with backend-as-a-service for live updates and seamless development.

Blog Image
Build Production-Ready GraphQL APIs with Apollo Server, TypeScript, and Redis Caching Tutorial

Build production-ready GraphQL APIs with Apollo Server 4, TypeScript, Prisma ORM & Redis caching. Master scalable architecture, authentication & performance optimization.

Blog Image
Build Type-Safe Event-Driven Microservices with NestJS, RabbitMQ, and Prisma Complete Guide

Learn to build scalable type-safe event-driven microservices with NestJS, RabbitMQ & Prisma. Complete guide with SAGA patterns, testing & deployment tips.