js

Build a Secure End-to-End Encrypted Chat App with Node.js and Signal Protocol

Learn to build an end-to-end encrypted chat app with Node.js, WebSockets, and Signal Protocol. Protect user messages with real E2EE.

Build a Secure End-to-End Encrypted Chat App with Node.js and Signal Protocol

I remember the moment I realized I had been building chat applications that were secure only in name. A friend, who works in cybersecurity, asked me a simple question: “If your server is compromised, can an attacker read all messages?” I froze. My API had TLS, I had authentication, I had encrypted storage—but the server could still decrypt everything. I was building a safe that locked the door but left the key inside. That’s when I decided to learn about end‑to‑end encryption, specifically the Signal Protocol. What I discovered changed how I think about trust forever.

Have you ever wondered why protecting messages from your own server matters? Most tutorials teach you to secure the connection with HTTPS. That’s like putting a letter in a locked box for delivery—but the postal worker can open it if they want. End‑to‑end encryption means the box is locked with a key that only you and your friend have. The postal worker cannot open it, even if they try. The Signal Protocol does this better than most alternatives because it adds forward secrecy: if someone steals your key today, they cannot read yesterday’s messages.

I will walk you through building a real‑time encrypted messaging system using Node.js, WebSockets, and the official Signal library. The server will never see plaintext—it only routes encrypted blobs. We will use the Double Ratchet algorithm and X3DH key exchange. Don’t worry if those terms sound scary. I will explain them step by step, with code you can copy and run.

First, let’s set up the foundation. Create a new Node.js project and install the dependencies. You need express, ws for WebSockets, @signalapp/libsignal-client for the protocol, and @prisma/client with prisma to store key bundles. Do not store private keys on the server. The server only holds public keys used for key agreement.

Here is the initial setup:

mkdir e2e-signal-chat && cd e2e-signal-chat
npm init -y
npm install express ws @prisma/client uuid dotenv @signalapp/libsignal-client
npm install -D typescript ts-node prisma @types/ws @types/express @types/node @types/uuid

Now configure TypeScript. I use strict mode and esModuleInterop because the Signal library expects it. Your tsconfig.json should look like this:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "lib": ["ES2022"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "resolveJsonModule": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules"]
}

The database schema needs to store users and their public keys. We use Prisma with PostgreSQL. The key point: the server never sees private keys. It only stores the public identity key, signed pre‑key, and one‑time pre‑keys. Each user also has a list of sessions, but the session state lives on the clients. Here is the schema:

model User {
  id              String    @id @default(uuid())
  username        String    @unique
  passwordHash    String
  identityKey     String
  signedPreKey    Json
  oneTimePreKeys  Json[]
  sessions        Session[]
}

model Session {
  id         String @id
  userId     String
  remoteUserId String
  sessionData String
}

Notice that sessions are stored too. That may seem contradictory—but sessions are encrypted with user‑specific keys. The server cannot read them.

Now we reach the core: key generation on the client. Every user must create an identity key pair, a signed pre‑key, and a bundle of one‑time pre‑keys. This happens before they connect to the server. You generate these keys using the Signal library. The private key stays on the device. The public keys are uploaded to the server.

Here is a function to generate a key bundle:

import { KeyPair, PreKeyBundle, IdentityKeyPair } from '@signalapp/libsignal-client';

export function generateKeyBundle() {
  const identityKeyPair = IdentityKeyPair.generate();
  const signedPreKey = KeyPair.generate();
  const oneTimePreKeys = Array.from({ length: 100 }, () => KeyPair.generate());

  return {
    identityKeyPair,
    signedPreKey,
    oneTimePreKeys,
  };
}

When Alice wants to send her first message to Bob, she must fetch Bob’s public key bundle from the server. She then performs the X3DH key agreement. This produces a shared secret that bootstraps the Double Ratchet. The Double Ratchet then rotates encryption keys for each message, providing forward secrecy.

Let me show you how to start a session. The client calls processPreKeyBundle and stores the result as a session record.

import { processPreKeyBundle, PreKeyBundle } from '@signalapp/libsignal-client';

function initializeSession(theirBundle: PreKeyBundle) {
  const session = processPreKeyBundle(
    theirBundle,
    myIdentityKeyPair.privateKey,
    mySignedPreKey.privateKey,
    theirBundle.identityKey
  );
  // Store session locally
  return session;
}

Once a session exists, encrypting a message is straightforward. You use encrypt with the session and plaintext.

function encryptMessage(session: any, plaintext: string) {
  return session.encrypt(plaintext);
}

The output is a CiphertextMessage object. You serialize it to base64 and send it over WebSocket to the server. The server looks up the recipient and forwards the ciphertext.

Now the WebSocket server. We need a hub that maintains connections per user. When a message arrives, the server checks the recipient’s username and forwards the payload. The server never inspects the content.

import WebSocket from 'ws';

const connections = new Map<string, WebSocket>();

wss.on('connection', (ws, req) => {
  const userId = authenticate(req); // Extract JWT
  connections.set(userId, ws);

  ws.on('message', (data) => {
    const msg = JSON.parse(data.toString());
    const { recipient, ciphertext } = msg;
    const recipientSocket = connections.get(recipient);
    if (recipientSocket) {
      recipientSocket.send(JSON.stringify({ from: userId, ciphertext }));
    } else {
      // Store offline message in database
    }
  });
});

What about offline messages? When the recipient is not connected, the server saves the ciphertext in the Message table. The recipient later fetches pending messages via an HTTP endpoint. Again, the server only sees base64 strings.

Here is the offline storage endpoint:

app.post('/messages', async (req, res) => {
  const { recipient, ciphertext } = req.body;
  await prisma.message.create({
    data: { senderId: req.user.id, recipientId: recipient, ciphertext }
  });
  res.json({ status: 'stored' });
});

Now, after this entire setup, you may ask: is it truly secure? Yes, but only if the client implementations are correct. The Signal Protocol library handles the cryptography, but you still need to ensure that private keys never leave the device. Use the built‑in storage mechanisms of the platform—Keychain on iOS, Keystore on Android, or in Node.js you can encrypt the private keys with a user password.

I also want to mention a nuance about the sealed sender. Signal’s protocol allows the sender to hide their identity from the server. The server knows the recipient but not who sent it. This is optional but useful for privacy. Implementation requires an additional encryption layer around the sender ID.

A common mistake is to ignore key rotation. Users should rotate one‑time pre‑keys periodically. The server can pre‑generate bundles and allow clients to replenish them. Without rotation, you may run out of one‑time keys, falling back to less secure mechanisms.

Let me add a personal touch. When I first built this, I tested with two browser tabs. I typed “Hello, world!” and watched the server logs. I saw only a garbled string. That moment gave me real confidence. No one but the two tabs knew what I said. That feeling of ownership over your data is powerful.

Now, about conclusions. If you found this helpful, please like, share, and comment below. Have you ever built a system where you could not read your own users’ data? I’m curious about your experiences. Drop a comment with your biggest challenge in E2EE.

This article is just a starting point. The protocol has many layers: group messaging, message receipts, quantum‑resistancy. But the foundation here gives you the power to build a truly private chat. Use it wisely.


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 chat app, WebSockets, secure messaging



Similar Posts
Blog Image
How to Integrate Next.js with Prisma ORM: Complete Guide for Type-Safe Full-Stack Development

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

Blog Image
How to Build Production-Ready Event-Driven Microservices with NestJS, Redis Streams and Docker

Learn to build production-ready event-driven microservices with NestJS, Redis Streams & Docker. Complete guide with CQRS, error handling & scaling tips.

Blog Image
How to Build Type-Safe GraphQL APIs with NestJS, Prisma, and Code-First Development

Learn to build type-safe GraphQL APIs with NestJS code-first approach, Prisma ORM integration, authentication, optimization, and testing strategies.

Blog Image
Master Event-Driven Architecture: Complete Node.js EventStore TypeScript Guide with CQRS Implementation

Learn to build event-driven architecture with Node.js, EventStore & TypeScript. Master CQRS, event sourcing, aggregates & projections with hands-on examples.

Blog Image
Complete Svelte Supabase Integration Guide: Build Full-Stack Apps in 2024

Learn how to build powerful full-stack apps by integrating Svelte with Supabase. Discover seamless authentication, real-time data sync, and rapid development tips.

Blog Image
Complete Guide: Building Full-Stack Applications with Next.js and Prisma Integration in 2024

Learn to integrate Next.js with Prisma for seamless full-stack development. Build type-safe applications with modern database operations and improved productivity.