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
- User registration: client generates a key pair, encrypts the private key with password, sends public key + encrypted private key + nonce. Server stores them.
- User login: server returns JWT and the encrypted private key blob. Client decrypts the blob locally with the password, holds private key in memory.
- Send message: client fetches recipient’s public key from the API, encrypts plaintext with sealed box, posts ciphertext to server.
- 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