js

Build a Real End-to-End Encrypted Messaging API with Node.js, Prisma, and Libsodium

Learn to build an end-to-end encrypted messaging API with Node.js, Prisma, and Libsodium—no plaintext on servers. Start securing messages now.

Build a Real End-to-End Encrypted Messaging API with Node.js, Prisma, and Libsodium

I’ll never forget the call. A startup founder had built a secure messaging app, but when they showed me the database, every message was stored in plaintext. “We use HTTPS,” he said. I had to explain that HTTPS only protects the wires, not the server, not the database, not the DBA with a coffee and a terminal. That’s when I decided to build a real end‑to‑end encrypted messaging API. No shortcuts. No plaintext on the server. Just sodium, Prisma, and some stubborn JavaScript.

What is E2EE and why should you care?

Think of a physical letter. You seal it in an envelope (HTTPS), but the postal service can still open it. End‑to‑end encryption means only the sender and recipient have the key to open the envelope. The postal service – or in our case, the server and its database – sees only meaningless gobbledygook. This matters for healthcare, for internal HR chat, and for any product where trust is the currency.

We’ll use libsodium, a battle‑tested crypto library derived from NaCl. It offers symmetric encryption (secretbox) and asymmetric encryption (box). For messages, we’ll use crypto_box – a public‑key authenticated encryption where the sender uses the recipient’s public key and their own private key. The server never sees plaintext.

The architecture in plain English

  • Each user generates a Curve25519 key pair on the client side (or in our API, but the private key leaves the server encrypted with a user’s password).
  • The public key is stored in the database openly (it’s designed to be public).
  • The private key is encrypted with the user’s password (or a derived key) and stored as an opaque blob. The server never holds the raw private key.
  • When Alice sends a message to Bob, the API encrypts the message using Alice’s private key (if she’s authenticated) and Bob’s public key. It produces a ciphertext and a nonce.
  • The ciphertext and nonce go into the database. The server can index by sender/recipient IDs but cannot read the content.
  • Bob downloads the message and decrypts it with his private key.

But we can do better: we can also let the server do the encryption only when the client provides the recipient’s public key and the sender’s private key (which the client must send along with the message – but with care). I’ll show the sealed‑box approach, where the server never needs the sender’s private key to encrypt a message.

Setting up the foundation

First, install the usual suspects:

npm init -y
npm install express prisma @prisma/client libsodium-wrappers jsonwebtoken bcrypt zod
npm install -D typescript ts-node @types/node @types/express vitest

Initialize Prisma with PostgreSQL. Our schema needs a User table with fields for the public key and an encrypted private key bundle, and a Message table with ciphertext and nonce.

model User {
  id                      String   @id @default(uuid())
  email                   String   @unique
  passwordHash            String
  publicKey               String   // base64 public key
  encryptedPrivateKey     String   // base64 of encrypted private key
  privateKeyNonce         String   // nonce used to encrypt the private key
  keyVersion              Int      @default(1)
  sentMessages            Message[] @relation("SentMessages")
  receivedMessages        Message[] @relation("ReceivedMessages")
}

model Message {
  id              String   @id @default(uuid())
  senderId        String
  recipientId     String
  ciphertext      String   // base64 encrypted payload
  nonce           String   // base64 nonce used for this message
  createdAt       DateTime @default(now())
}

Notice: no plaintext field. Even a full dump of the database reveals nothing.

The crypto piece – libsodium in Node

Before every crypto operation, you must wait for libsodium to be ready.

import _sodium from 'libsodium-wrappers';

export async function readySodium() {
  await _sodium.ready;
  return _sodium;
}

Generating a key pair is a single line:

export async function createKeyPair() {
  const sodium = await readySodium();
  return sodium.crypto_box_keypair();
}

Now, when a user registers, we generate their keys. We encrypt the private key with a derived key from their password. This way the server never sees the raw private key.

export async function encryptPrivateKey(privateKey: Uint8Array, password: string) {
  const sodium = await readySodium();
  const derivedKey = sodium.crypto_pwhash(
    32, // key length
    password,
    sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES), // salt
    sodium.crypto_pwhash_OPSLIMIT_MODERATE,
    sodium.crypto_pwhash_MEMLIMIT_MODERATE,
    sodium.crypto_pwhash_ALG_ARGON2ID13
  );
  const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
  const encrypted = sodium.crypto_secretbox_easy(privateKey, nonce, derivedKey);
  return { encrypted: sodium.to_base64(encrypted), nonce: sodium.to_base64(nonce) };
}

Store encrypted and nonce in the user record. The server can store the public key in the clear.

Sending a message – sealed‑box style

When Alice sends a message to Bob, she (or the API) needs Bob’s public key. Since the server has it, Alice can fetch it. We’ll encrypt the message with Bob’s public key using the sealed box method, which does not require the sender’s private key. This means the server can encrypt on behalf of a client without ever holding the sender’s private key.

export async function encryptForRecipient(plaintext: string, recipientPublicKeyBase64: string) {
  const sodium = await readySodium();
  const recipientPublicKey = sodium.from_base64(recipientPublicKeyBase64);
  const result = sodium.crypto_box_seal(plaintext, recipientPublicKey);
  // result is a single Uint8Array containing (ephemeral public key + ciphertext + nonce)
  return sodium.to_base64(result);
}

Store that string in the ciphertext field of the Message row. The nonce and ephemeral key are already embedded in the sealed box.

But wait – how does Bob decrypt? He calls a decryption endpoint, passing his own private key (which he retrieved from the encrypted blob stored in his user record, then decrypted with his password on the client side). The server will never receive the private key – we keep the decryption on the client. However, for a simpler API, you could have the server decrypt if the client provides the password at request time. I prefer client‑side decryption exclusively.

Storing and retrieving encrypted data with Prisma

Creating a message is straightforward:

const message = await prisma.message.create({
  data: {
    senderId: alice.id,
    recipientId: bob.id,
    ciphertext: encryptedPayload, // base64 string
    nonce: '' // not used in sealed box; we can set it empty
  }
});

To retrieve, Bob fetches messages where he is the recipient, then decrypts the ciphertext. Because the server cannot decrypt, we must ensure Bob has his private key (decrypted on his device). He downloads the blob and decrypts with:

export async function decryptSealed(ciphertextBase64: string, senderPublicKeyBase64: string, privateKeyUint8: Uint8Array) {
  const sodium = await readySodium();
  const ciphertext = sodium.from_base64(ciphertextBase64);
  const senderPublicKey = sodium.from_base64(senderPublicKeyBase64);
  // For sealed‑box, you need the private key of the recipient
  const decrypted = sodium.crypto_box_seal_open(ciphertext, senderPublicKey, privateKeyUint8);
  return sodium.to_string(decrypted);
}

Personal mistake: I once stored the private key of the user in the database in plaintext, thinking “the server is secure.” One development environment got compromised, and I had to rotate every user’s key. That’s why I now always encrypt the private key with the user’s password before storing it.

The practical flow – step by step

  1. User registration: client generates a key pair, encrypts the private key with password, sends public key + encrypted private key + nonce. Server stores them.
  2. User login: server returns JWT and the encrypted private key blob. Client decrypts the blob locally with the password, holds private key in memory.
  3. Send message: client fetches recipient’s public key from the API, encrypts plaintext with sealed box, posts ciphertext to server.
  4. Receive message: client GETs messages, uses own private key and sender’s public key to decrypt.

Every transmission is over TLS, but even if that were stripped, the ciphertext remains safe.

Testing the whole thing

Write an integration test that creates two users, sends a message, and verifies the recipient can decrypt it. Use Vitest.

import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import request from 'supertest';
import { app } from '../app';

describe('E2EE Messaging', () => {
  let aliceToken, bobToken;
  let aliceKeyPair, bobKeyPair;

  beforeAll(async () => {
    // register alice and bob, store key pairs
  });

  it('sends and decrypts a message', async () => {
    const res = await request(app)
      .post('/messages')
      .set('Authorization', `Bearer ${aliceToken}`)
      .send({ recipientId: bob.id, plaintext: 'Hello, Bob!' });

    // Bob fetches his messages
    const messagesRes = await request(app)
      .get('/messages')
      .set('Authorization', `Bearer ${bobToken}`);
    const encrypted = messagesRes.body.messages[0].ciphertext;
    const decrypted = decryptSealed(encrypted, alicePublicKey, bobPrivateKey);
    expect(decrypted).toBe('Hello, Bob!');
  });
});

But what if Bob loses his password? That’s a problem. The encrypted private key is useless without the password. You can implement a recovery mechanism using a passphrase or a secondary key escrow – but that’s a whole other article.

Why this approach beats simpler solutions

Many tutorials show how to encrypt with a server‑side secret key. That’s not E2EE – the server can decrypt. Our design ensures that even if an attacker gets full database read access plus the application code, they cannot decrypt any past or future messages without the users’ private keys. That’s the difference between “secure” and “truly private.”

Closing

I hope this walkthrough shows that building a real E2EE API is not magic – it’s just careful key management and a few library calls. The hard part is the discipline: never store the raw private key, never let the server see plaintext, and test every path.

If this article helped you, like it so others can find it. Share it with a developer who thinks HTTPS is enough. And if you have questions or want to discuss key rotation, leave a comment – I read every single one. Let’s build a more private web, one sealed box at a time.


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, secure messaging API, Node.js, Prisma, Libsodium



Similar Posts
Blog Image
How to Build a Real-Time Multiplayer Game Engine: Socket.io, Redis & TypeScript Complete Guide

Learn to build scalable real-time multiplayer games with Socket.io, Redis, and TypeScript. Master state management, lag compensation, and authoritative servers.

Blog Image
Build Type-Safe REST APIs with Fastify, Zod, and Prisma: Complete TypeScript Guide

Learn to build production-ready REST APIs with Fastify, Zod & Prisma. Complete TypeScript guide with validation, testing & advanced features.

Blog Image
Build Complete Event-Driven Architecture with RabbitMQ TypeScript Microservices Tutorial

Learn to build scalable event-driven microservices with RabbitMQ & TypeScript. Master event sourcing, CQRS, error handling & production deployment.

Blog Image
Next.js Prisma Integration Guide: Build Type-Safe Full-Stack Apps with Database Management

Learn how to integrate Next.js with Prisma for type-safe full-stack development. Build modern web apps with seamless database management and TypeScript support.

Blog Image
Complete Guide to Next.js Prisma Integration: Build Type-Safe Full-Stack Apps in 2024

Learn how to integrate Next.js with Prisma ORM for type-safe database operations. Build full-stack apps with seamless React-to-database connectivity.

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

Learn how to integrate Next.js with Prisma ORM for type-safe, full-stack applications. Build powerful database-driven web apps with ease. Start building today!