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
How to Build a Type-Safe, Dynamic Gateway for Microservices with Envoy and Consul

Learn to create a resilient, type-safe gateway using Envoy, Consul, and TypeScript for smarter microservice traffic management.

Blog Image
Build Multi-Tenant SaaS with NestJS, Prisma, PostgreSQL RLS: Complete 2024 Guide

Learn to build secure multi-tenant SaaS apps with NestJS, Prisma & PostgreSQL RLS. Complete guide covering tenant isolation, auth & performance optimization.

Blog Image
Complete Guide to Integrating Next.js with Prisma ORM for Type-Safe Database Operations

Learn how to integrate Next.js with Prisma ORM for type-safe database operations, faster development, and seamless full-stack applications. Complete setup guide inside.

Blog Image
Complete Guide to Building Event-Driven Architecture with Apache Kafka and Node.js

Learn to build scalable event-driven systems with Apache Kafka and Node.js. Complete guide covering setup, type-safe clients, event sourcing, and monitoring. Start building today!

Blog Image
BullMQ TypeScript Guide: Build Type-Safe Background Job Processing with Redis Queue Management

Learn to build scalable, type-safe background job processing with BullMQ, TypeScript & Redis. Includes monitoring, error handling & production deployment tips.

Blog Image
Master BullMQ, Redis & TypeScript: Build Production-Ready Distributed Job Processing Systems

Learn to build scalable distributed job processing systems using BullMQ, Redis & TypeScript. Complete guide covers queues, workers, error handling & monitoring.