js

How to Implement End-to-End Encryption in a Chat App with Signal Protocol and libsodium

Learn how to implement end-to-end encryption in a chat app using Signal Protocol and libsodium for secure, private messaging.

How to Implement End-to-End Encryption in a Chat App with Signal Protocol and libsodium

I remember the exact moment I realized my chat app was fundamentally broken. A friend sent me a sensitive message, and I knew—beyond any doubt—that if our server were ever compromised, that message would be exposed. Transport-layer encryption only protects data in transit, not from the server itself. That’s when I decided to implement true end-to-end encryption (E2EE) using the Signal Protocol and libsodium. It’s not as complicated as it sounds, but it requires careful attention to every cryptographic detail. Let me walk you through exactly how I did it, and how you can too.

First, let’s understand what we’re protecting against. Imagine you own a coffee shop. TLS is like having a secure courier deliver a sealed envelope from your shop to another. The courier can’t open it, but the barista at the other end can, and so can anyone who finds the envelope lying around. E2EE is like writing the message in invisible ink that only the recipient knows how to read. Even the courier, the coffee shop owner, and the government can’t read it. The server carries the ciphertext, but it never sees the plaintext. That’s the core promise of E2EE.

The Signal Protocol rests on two main pillars: the X3DH key agreement protocol (which establishes a shared secret between two users without ever exchanging it directly) and the Double Ratchet algorithm (which provides forward secrecy—if a key is leaked, only a tiny window of messages is compromised, not the entire history). libsodium gives us fast, audited implementations of the underlying primitives: X25519 for key exchange, Ed25519 for signing, AES-GCM for symmetric encryption, and BLAKE2b for hashing.

Now, how does a user start? Every client generates a permanent identity key pair (like a passport), a signed pre-key (rotated periodically), and a batch of one-time pre-keys (used only once, preventing replay attacks). The public parts of these keys are uploaded to a key server. When Alice wants to send her first message to Bob, she downloads Bob’s public key bundle, performs the X3DH handshake to derive a shared secret, and then uses that to initialize a Double Ratchet session. Let me show you the code for generating these keys.

import sodium from 'libsodium-wrappers';

export async function generateKeyBundle() {
  await sodium.ready;
  // Identity key (Ed25519) – permanent and unique per user
  const identityKeyPair = sodium.crypto_sign_keypair();
  // Signed pre-key (X25519) – medium-lived, signed by identity key
  const signedPreKeyPair = sodium.crypto_kx_keypair();
  const signature = sodium.crypto_sign_detached(
    signedPreKeyPair.publicKey,
    identityKeyPair.privateKey
  );
  // One-time pre-keys (X25519) – used only once, then discarded
  const oneTimePreKeys = Array.from({ length: 10 }, () => sodium.crypto_kx_keypair());

  return {
    identityPublicKey: identityKeyPair.publicKey,
    identityPrivateKey: identityKeyPair.privateKey,
    signedPreKey: {
      keyId: 1,
      publicKey: signedPreKeyPair.publicKey,
      privateKey: signedPreKeyPair.privateKey,
      signature,
    },
    oneTimePreKeys: oneTimePreKeys.map((kp, i) => ({
      keyId: i + 1,
      publicKey: kp.publicKey,
      privateKey: kp.privateKey,
    })),
  };
}

Notice that private keys are stored only on the device. The server never sees them. When Alice sends her first message, she must also include an ephemeral key and the ID of the one-time pre-key she used from Bob’s bundle. The server relays this initial ciphertext, but cannot decrypt it because it lacks the shared secret.

“But how does Bob decrypt the message?” you might ask. Bob’s client receives the ephemeral key and uses its own private keys to recompute the same shared secret via X3DH. Then it initializes its own Double Ratchet state. From that point on, both sides independently maintain a chain of symmetric keys that are updated after every message. This ensures that even if an attacker steals the current ratchet key, they cannot decrypt past or future messages.

Let’s look at the actual message encryption step. After the shared secret is derived, we use it to generate a root key and a sending chain key. Each message gets a fresh encryption key derived from the chain using a KDF. The message payload is encrypted with AES-GCM, which provides both confidentiality and authentication.

import sodium from 'libsodium-wrappers';

export async function encryptMessage(
  plaintext: string,
  chainKey: Uint8Array,
  messageIndex: number
) {
  await sodium.ready;
  const messageKey = sodium.crypto_generichash(32, 
    Buffer.concat([chainKey, Buffer.from([messageIndex])])
  );
  const nonce = sodium.randombytes_buf(12);
  const ciphertext = sodium.crypto_aead_aes256gcm_encrypt(
    plaintext,
    null, // additional data
    null, // secret nonce
    nonce,
    messageKey
  );
  return { ciphertext, nonce, messageIndex };
}

You might wonder about the one-time pre-keys. Why not just use the signed pre-key forever? Because an adversary could force the same signed pre-key to be reused, compromising forward secrecy. By supplying a fresh key every time, we guarantee that even if the user’s long-term keys are later stolen, old sessions remain safe.

I also added a hard requirement that the client must verify the identity key signature when receiving a public bundle. Without that step, a man-in-the-middle could substitute their own keys. The verification is straightforward: check that Bob’s signed pre-key’s signature matches his identity key using Ed25519 verify.

One thing that surprised me during implementation was the metadata exposure. Even with perfect E2EE, the server still knows who is talking to whom and when. That’s why some apps combine it with metadata protection like mix networks or private contact discovery. For most applications, though, the trade-off is acceptable. The important part is that content is never visible to anyone but the intended recipient.

Testing this system required building a simple Fastify WebSocket server that just relays ciphertext. No decryption, no storing of message content beyond temporary delivery queues. I used libsodium’s constant-time comparison to check message authentication tags, preventing timing attacks.

Finally, I want to stress that you should never roll your own crypto. The Signal Protocol is battle-tested, and libsodium is a high-level library that avoids common pitfalls. Use established implementations like @signalapp/libsignal-client for Node.js rather than writing the X3DH handshake from scratch, unless you have a very good reason.

If you’re building a chat app today, ask yourself: does the user trust your server? If the answer is anything less than “absolutely”, you need E2EE. It’s not about being paranoid; it’s about respecting the privacy of the people who rely on your software.

Now, I’d love to hear your thoughts. Did this walkthrough help you understand the core concepts? Have you ever attempted to implement E2EE, or do you have questions about a specific step? Drop a comment below, share this article with anyone building secure systems, and don’t forget to subscribe for more practical cryptography deep dives. The more we talk about these implementations, the safer our digital conversations become.


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, libsodium, secure chat app, E2EE implementation



Similar Posts
Blog Image
Building High-Performance GraphQL APIs: NestJS, Prisma, and Redis Caching Complete Guide

Learn to build scalable GraphQL APIs with NestJS, Prisma ORM, and Redis caching. Master DataLoader optimization, real-time subscriptions, and production-ready performance techniques.

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

Learn how to integrate Next.js with Prisma ORM for type-safe, full-stack web applications. Build robust data-driven apps with seamless database operations.

Blog Image
Complete Node.js Logging System: Winston, OpenTelemetry, and ELK Stack Integration Guide

Learn to build a complete Node.js logging system using Winston, OpenTelemetry, and ELK Stack. Includes distributed tracing, structured logging, and monitoring setup for production environments.

Blog Image
Complete Event-Driven Microservices Architecture with NestJS, RabbitMQ, and MongoDB Guide

Learn to build scalable event-driven microservices with NestJS, RabbitMQ & MongoDB. Master CQRS, event sourcing, distributed transactions & deployment strategies.

Blog Image
Build Production-Ready GraphQL APIs with NestJS, Prisma, and Redis: Complete Developer Guide

Learn to build scalable GraphQL APIs with NestJS, Prisma, and Redis caching. Master authentication, real-time subscriptions, and production deployment.

Blog Image
Master API Rate Limiting: Complete Redis Express.js Implementation Guide with Production Examples

Learn to build production-ready API rate limiting with Redis and Express.js. Complete guide covering algorithms, implementation, security, and deployment best practices.