How to Build End-to-End Encrypted Messaging with Node.js, Libsodium, and Prisma

Learn how to build end-to-end encrypted messaging with Node.js, Libsodium, and Prisma to protect user data and secure private chats.

How to Build End-to-End Encrypted Messaging with Node.js, Libsodium, and Prisma

I remember the day I accidentally printed user passwords in a debug log. Nothing was leaked, but the incident forced me to rethink security. That night I asked myself: how do you build a system that is safe even when the server—you—are compromised? The answer is end-to-end encryption. In a messaging app, the server stores only ciphertext. The sender and recipient hold the keys. The server never sees plaintext. This article walks through a practical implementation using Node.js, libsodium, and Prisma.

Let’s start with a question: have you ever trusted a service with your private conversations? If you haven’t, you probably should. Because even well‑intentioned servers can be breached, subpoenaed, or misconfigured. End‑to‑end encryption (E2EE) removes that trust. The server becomes a dumb bucket of encrypted blobs.

I’ll assume you know TypeScript basics and have Node.js 20+ installed. We’ll use Express for the API, Prisma with PostgreSQL for storage, and libsodium‑wrappers for cryptography. The code is production‑grade but simplified for learning.

mkdir e2ee-messaging && cd e2ee-messaging
npm init -y
npm install express prisma @prisma/client libsodium-wrappers jsonwebtoken zod
npm install -D typescript ts-node @types/express @types/node @types/jsonwebtoken vitest
npx tsc --init
npx prisma init

The database schema is intentionally minimal. Each user stores their public key (base64). Private keys live only on the client. The message table holds ciphertext, nonce, and an optional ephemeral public key for key exchange.

model User {
  id            String   @id @default(cuid())
  publicKey     String   @unique
  // privateKey is NEVER stored
  createdAt     DateTime @default(now())
  sentMessages     Message[] @relation("SentMessages")
  receivedMessages Message[] @relation("ReceivedMessages")
}

model Message {
  id              String   @id @default(cuid())
  ciphertext      String
  nonce           String
  senderId        String
  recipientId     String
  createdAt       DateTime @default(now())

  sender    User @relation("SentMessages",    fields: [senderId],    references: [id])
  recipient User @relation("ReceivedMessages", fields: [recipientId], references: [id])
}

Why no private key column? Because if we store it, an attacker who gets the database can read everything. The whole point of E2EE is that the server cannot decrypt. So we never see the private key. The user generates keys on their device and uploads only the public part.

Now the crypto core. Libsodium is a battle‑tested library. We’ll use the crypto_box primitive: it combines X25519 key exchange with XSalsa20‑Poly1305 authenticated encryption. First‑person tip: always call sodium.ready before any operation.

import _sodium from 'libsodium-wrappers';
let sodium: typeof _sodium;

async function getSodium() {
  if (!sodium) {
    await _sodium.ready;
    sodium = _sodium;
  }
  return sodium;
}

export async function generateKeyPair() {
  const na = await getSodium();
  const keyPair = na.crypto_box_keypair();
  return {
    publicKey: na.to_base64(keyPair.publicKey, na.base64_variants.URLSAFE_NO_PADDING),
    privateKey: na.to_base64(keyPair.privateKey, na.base64_variants.URLSAFE_NO_PADDING),
  };
}

Notice the URL‑safe base64 variant. It avoids characters like + or / that can be mangled in URLs or JSON. A small detail that saves hours of debugging.

Encryption: Alice wants to send a message to Bob. She needs Bob’s public key (which she fetches from the server) and her own private key. The box encryption derives a shared secret using Diffie‑Hellman and encrypts the plaintext.

export async function encryptMessage(
  plaintext: string,
  recipientPublicKey: string,
  senderPrivateKey: string
) {
  const na = await getSodium();
  const recipientPub = na.from_base64(recipientPublicKey, na.base64_variants.URLSAFE_NO_PADDING);
  const senderPriv = na.from_base64(senderPrivateKey, na.base64_variants.URLSAFE_NO_PADDING);
  const nonce = na.randombytes_buf(na.crypto_box_NONCEBYTES);
  const ciphertext = na.crypto_box_easy(
    na.from_string(plaintext),
    nonce,
    recipientPub,
    senderPriv
  );
  return {
    ciphertext: na.to_base64(ciphertext, na.base64_variants.URLSAFE_NO_PADDING),
    nonce: na.to_base64(nonce, na.base64_variants.URLSAFE_NO_PADDING),
  };
}

How does Bob decrypt? He uses his private key and Alice’s public key. He also needs the nonce, which is stored alongside the ciphertext.

export async function decryptMessage(
  ciphertextB64: string,
  nonceB64: string,
  senderPublicKey: string,
  recipientPrivateKey: string
) {
  const na = await getSodium();
  const ciphertext = na.from_base64(ciphertextB64, na.base64_variants.URLSAFE_NO_PADDING);
  const nonce = na.from_base64(nonceB64, na.base64_variants.URLSAFE_NO_PADDING);
  const senderPub = na.from_base64(senderPublicKey, na.base64_variants.URLSAFE_NO_PADDING);
  const recipientPriv = na.from_base64(recipientPrivateKey, na.base64_variants.URLSAFE_NO_PADDING);
  const plaintext = na.crypto_box_open_easy(ciphertext, nonce, senderPub, recipientPriv);
  if (plaintext === false) throw new Error('Decryption failed – forged or tampered message');
  return na.to_string(plaintext);
}

If the recipient’s private key doesn’t match, or if the ciphertext was altered, crypto_box_open_easy returns false. Never trust a message that fails this check.

Now the API endpoints. I’ll show two of the most critical ones.

First, user registration. The client generates a key pair and sends the public key. The server stores it.

app.post('/api/users', async (req, res) => {
  const { publicKey } = req.body;
  // Validate with Zod
  const user = await prisma.user.create({ data: { publicKey } });
  res.json({ userId: user.id });
});

Second, sending a message. The client fetches the recipient’s public key, encrypts locally, and POSTs the ciphertext and nonce.

app.post('/api/messages', async (req, res) => {
  const { senderId, recipientId, ciphertext, nonce } = req.body;
  await prisma.message.create({
    data: { senderId, recipientId, ciphertext, nonce }
  });
  res.status(201).send();
});

Notice: the server never sees who sent what plaintext. It just stores data.

Have you considered what happens if a user loses their private key? All messages become permanently unreadable. This is a feature, not a bug. To mitigate it, you could implement a key escrow or recovery mechanism, but that reintroduces trust. For a truly private system, you accept the trade‑off.

What about forward secrecy? The standard box algorithm uses a long‑term key pair. If a private key is stolen later, all past messages become readable. To achieve forward secrecy, you need something like the Signal Protocol (X3DH + double ratchet). That’s beyond this article, but it’s a natural next step.

One more personal touch: always test your encryption round‑trip with known vectors. I once had a bug where server and client used different base64 variants. It took hours to find because the error was just “decryption failed.” Write a unit test that encrypts a known string with a known key pair and verifies the ciphertext matches a pre‑computed value.

import { generateKeyPair, encryptMessage, decryptMessage } from './crypto.service';

test('encrypt then decrypt returns original', async () => {
  const alice = await generateKeyPair();
  const bob = await generateKeyPair();
  const { ciphertext, nonce } = await encryptMessage('Hello Bob', bob.publicKey, alice.privateKey);
  const decrypted = await decryptMessage(ciphertext, nonce, alice.publicKey, bob.privateKey);
  expect(decrypted).toBe('Hello Bob');
});

If you’ve read this far, you already understand why E2EE matters. But here’s the hard part: you must ensure your client never leaks the private key. Use the Web Crypto API in browsers, or a secure enclave on mobile. Never log the private key. Never send it to the server. And never, ever hardcode test keys in a public repository.

I hope this article helped you see how straightforward a basic E2EE system can be. The complexity lies in the details—key management, protocol design, and operational security. But the core principle is simple: encrypt before sending, only the intended recipient can decrypt.

If you found this useful, please like, share, and leave a comment with your thoughts or questions. What topic should I tackle next? Maybe forward secrecy or a web client? Let me know.


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

// Our Network

More from our team

Explore our publications across finance, culture, tech, and beyond.

// More Articles

Similar Posts