js

Building End-to-End Encrypted Chat in Node.js with Signal Protocol and WebSockets

Learn how to build end-to-end encrypted chat in Node.js using Signal Protocol and WebSockets, with practical steps for secure messaging.

Building End-to-End Encrypted Chat in Node.js with Signal Protocol and WebSockets

I have been building chat applications for years, but every time I reached the part where the server could read the messages, I felt a pang of unease. Transport Layer Security is great, but it only protects the wire. The server still sees everything. So I decided to build a system where even I, the developer, cannot read a single message. This is the story of implementing end-to-end encrypted messaging using the Signal Protocol in Node.js with WebSockets.

Have you ever wondered why most messaging apps claim to be secure but still hand your plaintext to the server? The answer is that true end-to-end encryption is hard. The Signal Protocol is the gold standard. It uses a clever combination of the X3DH key agreement and the Double Ratchet algorithm to provide forward secrecy and future secrecy. Let me walk you through how I built it.

I started by understanding the core ideas. X3DH allows two parties to agree on a shared secret even if one of them is offline. The server stores prekey bundles for each user: the identity key, signed prekey, and a batch of one‑time prekeys. When Alice wants to talk to Bob, she fetches Bob’s bundle, performs four Diffie‑Hellman exchanges, and derives a shared secret. That secret seeds the Double Ratchet, which then generates new encryption keys for every message and every direction change. The result: if an attacker steals your session state, they can only decrypt the current message, not the entire history.

I set up the project with TypeScript for type safety and used the official @signalapp/libsignal-client library for the heavy lifting. But I wanted to understand the details, so I also implemented a version using the low‑level @noble/curves and @noble/hashes libraries. Here is a simplified sketch of how I generated identity keys:

import { generatePrivateKey, getPublicKey } from '@noble/curves/ed25519';

const identityPrivateKey = generatePrivateKey();
const identityPublicKey = getPublicKey(identityPrivateKey);

But the Signal Protocol uses X25519 keys for the ratchet, not Ed25519. So I used the x448 curve for modern performance. The important thing is that each user stores a persistent identity key pair and a signed prekey pair. The signed prekey’s public key is signed with the identity key to prove ownership.

The server acts as a key distribution center and a relay for encrypted messages. I built a WebSocket server with ws and Express for the REST API that clients use to register and fetch key bundles. When a new user signs up, they generate their identity keys, signed prekey, and a pool of one‑time prekeys. They upload the public parts to the server. The server stores them in a simple SQLite database.

// Server endpoint to register prekey bundle
app.post('/keys', async (req, res) => {
  const { userId, identityKey, signedPrekey, signature, oneTimePrekeys } = req.body;
  // Verify signature, store in database
  await db.run(
    'INSERT INTO keys (userId, identityKey, signedPrekey, signature) VALUES (?,?,?,?)',
    userId, identityKey, signedPrekey, signature
  );
  await db.insertOneTimePrekeys(userId, oneTimePrekeys);
  res.json({ ok: true });
});

Now, when Alice wants to start a conversation with Bob, she requests his key bundle from the server. The server returns Bob’s identity public key, signed prekey, signature, and one remaining one‑time prekey. If no one‑time prekeys are left, the exchange can still proceed using only the signed prekey, but that reduces forward secrecy slightly. I made sure the server automatically replenishes prekeys when they run low.

The X3DH calculation on the client side looks like this:

import { x25519 } from '@noble/curves/ed25519';

function x3dh(ikAlice, ekAlice, ikBob, spkBob, opkBob) {
  const dh1 = x25519.scalarMult(ikAlice, spkBob);
  const dh2 = x25519.scalarMult(ekAlice, ikBob);
  const dh3 = x25519.scalarMult(ekAlice, spkBob);
  const dh4 = opkBob ? x25519.scalarMult(ekAlice, opkBob) : new Uint8Array(32);
  const sharedSecret = hkdf(dh1, dh2, dh3, dh4, 32);
  return sharedSecret;
}

I then used the @signalapp/libsignal-client library’s SessionBuilder to turn that shared secret into a proper session. The library handles the Double Ratchet internally. But if you want to implement it yourself, you need to maintain a root key and chain keys, and perform a DH ratchet whenever the message direction flips. Here is a snippet of my hand‑rolled Double Ratchet for educational purposes:

class DoubleRatchet {
  private rootKey: Uint8Array;
  private sendChainKey: Uint8Array;
  private recvChainKey: Uint8Array;
  private DHKeyPair: { private: Uint8Array; public: Uint8Array };

  ratchet(theirPublic: Uint8Array) {
    const shared = x25519.scalarMult(this.DHKeyPair.private, theirPublic);
    const [newRoot, chainKey] = KDF(this.rootKey, shared, 32);
    this.rootKey = newRoot;
    this.sendChainKey = chainKey;
    this.DHKeyPair = generateKeyPair(); // new ephemeral
  }
}

With the session established, messages are encrypted using AES‑256‑GCM with a key derived from the chain key. I appended the ephemeral public key and a message counter to each message so the receiver can step the ratchet accordingly.

The WebSocket layer carries the encrypted payload. I designed a simple protocol: each message has a type (e.g., “encrypted”, “key_request”), a sender ID, recipient ID, and the ciphertext. The server simply routes the message to the correct WebSocket connection.

ws.on('message', (data) => {
  const msg = JSON.parse(data.toString());
  const recipientSocket = onlineClients.get(msg.toUserId);
  if (recipientSocket) {
    recipientSocket.send(JSON.stringify({
      from: msg.fromUserId,
      ciphertext: msg.ciphertext,
      ephemeralKey: msg.ephemeralKey,
      counter: msg.counter
    }));
  } else {
    // Store for later delivery (offline)
  }
});

One thing that surprised me was the importance of handling session state correctly. If Alice sends a message, then Bob sends one, the ratchet needs to move in both directions. I had to track the “previous sending chain” and “receiving chain” carefully. The library handles that, but writing it myself taught me a lot.

I also added a feature: if a user loses their session state (e.g., clears local storage), they can request a new key bundle from the server. But the old encrypted messages become undecryptable. That is a feature, not a bug—forward secrecy at work.

Testing the whole flow required running two client instances. I wrote a simple test script that creates two users, exchanges keys, and sends messages. The server logs only encrypted bytes. No plaintext ever appears in the logs.

Now, what about production hardening? I used environment variables for database paths and set rate limits on key requests to prevent abuse. I also made sure the WebSocket server uses WSS with a valid TLS certificate. The key storage on the server is hashed with a server‑side secret so even if the database is leaked, the attacker cannot reuse the keys directly.

But there is a dark side: what if a user’s device is compromised? The protocol cannot prevent that. The best we can do is allow users to manually “reset” their session—generate new identity keys and prekeys—and re‑establish trust out of band.

I also learned that the libsignal library expects a specific serialization format for sessions. I used its built‑in serialization to store sessions in IndexedDB on the client. On the server, I stored nothing about the content of messages—only the metadata needed for routing.

Looking back, the most difficult part was integrating the low‑level crypto with the WebSocket state machine. There are many edge cases: out‑of‑order messages, lost messages, multiple devices. The Signal Protocol handles these with message keys and a skip window. I implemented a simple skip buffer that stores message keys for out‑of‑order messages up to a configured window.

I also made the system work with a single server instance. For true scale, you would need a distributed key‑value store like Redis for online presence and message queues. But the core encryption logic is horizontally scalable because it is stateless from the server’s perspective.

Now, you might ask: why not just use a library and be done? I did use the Signal library for the production version, but building a minimal version from scratch gave me confidence that I understand the underlying mechanisms. It also allowed me to customize the key storage and the rekey logic.

If you are planning to implement your own E2EE system, start with the basics. Use well‑tested libraries for the heavy crypto. Focus on key management and session persistence. And never trust your server. As developers, we must accept that our code is running on machines we do not control, and design for that reality.

I hope this walkthrough has given you a practical feel for how Signal Protocol works in Node.js. If you have any questions, leave them in the comments. Did you find this article useful? Share it with your fellow developers. And if you want me to cover any specific part in more detail, let me know. Like, share, and comment below to keep the conversation going.


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, Signal Protocol, Node.js, WebSockets, secure messaging



Similar Posts
Blog Image
Next.js Prisma Integration Guide: Build Type-Safe Full-Stack Apps with Modern Database Toolkit

Learn how to integrate Next.js with Prisma ORM for type-safe database operations. Build powerful full-stack apps with seamless data management. Start coding today!

Blog Image
Build Production-Ready CQRS Event Sourcing Systems with TypeScript, NestJS, and EventStore

Master Event Sourcing with TypeScript, EventStore & NestJS. Build production-ready CQRS systems with versioning, snapshots & monitoring. Start coding!

Blog Image
Next.js Prisma Integration: Build Type-Safe Full-Stack Applications with Modern Database Toolkit

Learn to integrate Next.js with Prisma for type-safe full-stack apps. Build robust web applications with seamless database operations and TypeScript support.

Blog Image
Build High-Performance Event-Driven Microservice with Fastify TypeScript RabbitMQ Complete Tutorial

Learn to build production-ready event-driven microservices with Fastify, TypeScript & RabbitMQ. Complete guide with Docker deployment & performance tips.

Blog Image
Complete Guide to Integrating Next.js with Prisma ORM for Type-Safe Full-Stack Applications

Learn how to integrate Next.js with Prisma ORM for building type-safe, full-stack web applications with seamless database operations and unified codebase.

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

Learn to integrate Svelte with Supabase for building real-time web applications. Master authentication, database operations, and live updates in this comprehensive guide.