How to Build an End-to-End Encrypted Messaging API with Node.js, Prisma, and Web Crypto
Learn to build an end-to-end encrypted messaging API with Node.js, Prisma, and Web Crypto using ECDH and AES-GCM. Start securely today.
I have been building secure applications for over a decade, and every time a client asked for “encrypted messaging” I had to stop them and explain the difference between encryption in transit (TLS) and end-to-end encryption. Most developers assume that if the database stores passwords as hashes and traffic uses HTTPS, the messages are safe. That is dangerously wrong. The server still sees plaintext. A breach, a rogue admin, or a court order can expose everything. True end-to-end encryption means the server never, ever gets to read the content. That required a shift in how I design APIs. I want to show you how I built a type‑safe, end‑to‑end encrypted messaging API using Node.js, Prisma, and the Web Crypto API. No third‑party magic. Just the primitives that browsers and Node.js already provide.
Have you ever wondered how Signal or WhatsApp ensure that even their own companies cannot read your chats? The answer is a hybrid of asymmetric and symmetric cryptography. Asymmetric algorithms like ECDH (Elliptic‑Curve Diffie‑Hellman) let two parties agree on a shared secret without ever sending that secret over the network. Symmetric algorithms like AES‑GCM then use that secret to encrypt the actual message. AES‑GCM is superior to AES‑CBC because it authenticates the ciphertext, so any tampering is immediately detected. I always default to GCM for any production encryption.
Let me walk you through the core flow. When Alice wants to send a secure message to Bob, both must first register their public keys with the server. The server stores only the public keys – the private keys never leave the clients. When Alice fetches Bob’s public key, she combines it with her own private key to compute a shared secret. Bob, using his private key and Alice’s public key, computes the exact same secret. That shared secret is then used to encrypt messages with AES‑GCM. The server receives only the ciphertext, the IV (initialization vector), and a version number. It cannot decrypt anything.
I start by setting up a TypeScript project with Express, Prisma, and PostgreSQL. The Prisma schema is critical. The User model stores a publicKey as a JSON Web Key (JWK) string. Why JWK? Because it is a standard format that works across platforms and languages, and it includes metadata like key type and algorithm. I also add a keyVersion field to support key rotation. The Message model stores the ciphertext, the iv, the authTag (authenticated tag from AES‑GCM), and keyVersion. Notice I never store the shared secret – it is ephemeral and derived per conversation.
model User {
id String @id @default(uuid())
username String @unique
publicKey String @db.Text
keyVersion Int @default(1)
// ... relations
}
model Message {
id String @id @default(uuid())
ciphertext String @db.Text
iv String
authTag String?
keyVersion Int @default(1)
// ... relations
}
Now for the cryptographic service. Node.js 15+ exposes crypto.subtle which mirrors the Web Crypto API. No need to install node-forge or crypto-js. I define helper functions for generating ECDH keys, deriving a shared secret, and encrypting/decrypting with AES‑GCM. The private key is stored in the client’s memory (or IndexedDB in browser clients) and never sent anywhere. The public key is exported as JWK and sent to the server during registration.
const subtle = crypto.subtle;
async function generateKeyPair(): Promise<CryptoKeyPair> {
return await subtle.generateKey(
{ name: 'ECDH', namedCurve: 'P-256' },
true,
['deriveKey', 'deriveBits']
);
}
async function deriveSharedSecret(
privateKey: CryptoKey,
publicKey: CryptoKey
): Promise<CryptoKey> {
return await subtle.deriveKey(
{ name: 'ECDH', public: publicKey },
privateKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}
When Alice sends a message, her client generates a random IV (12 bytes for GCM), encrypts the plaintext with the derived shared key, and sends the ciphertext, IV, and authTag to the server. The server does nothing but store these values and mark the message as sent. When Bob retrieves the message, his client reads the ciphertext, IV, authTag, and uses the same derived shared key to decrypt. The server remains blind.
Have you ever considered what happens if Alice’s private key is compromised? That is why I implement key rotation. Each time a user rotates their key pair, the keyVersion increments. Old messages remain encrypted with the old key. The client must know which key version was used to encrypt a message so it can derive the correct shared secret. The server stores the key version alongside the message. This is the core of forward secrecy: if a private key is stolen later, past messages are not automatically decrypted – the attacker would also need the old private key, which the client should have discarded after rotation.
In the API routes, I expose endpoints like POST /users (register with public key), GET /users/:id/public-key (retrieve the latest public key), and POST /messages (send an encrypted message). The server validates the data, but it never touches the cryptographic material. I also implement a KeyRotationLog table to track when keys were rotated, purely for auditing.
Let me show you a snippet of the message sending route with proper Express middleware and error handling:
router.post('/messages', authenticate, async (req, res) => {
const { receiverId, ciphertext, iv, authTag, keyVersion } = req.body;
// Validate required fields (never trust the client implicitly though)
if (!receiverId || !ciphertext || !iv || !authTag || !keyVersion) {
return res.status(400).json({ error: 'Missing required fields' });
}
const message = await prisma.message.create({
data: {
senderId: req.userId,
receiverId,
ciphertext,
iv,
authTag,
keyVersion,
},
});
res.status(201).json({ messageId: message.id });
});
Notice the server does not attempt to parse or validate the ciphertext beyond its existence. That is the point. The encrypted payload is opaque to the server. Authentication is still done via JWT or session tokens, but those only control access to the API, not the content.
What about message integrity? AES‑GCM already produces an authentication tag that verifies the ciphertext has not been modified. I store that tag separately for audit purposes, but typically the client can just check it during decryption. If the tag does not match, the message is discarded and the user is alerted.
I always add a personal touch: when I first implemented this, I accidentally stored the IV as a string without converting from ArrayBuffer to base64. The decryption failed silently, and I spent two hours debugging. Make sure you use proper encoding – I prefer base64url for both IV and ciphertext because it is URL‑safe and compact.
function ab2base64url(buffer: ArrayBuffer): string {
return Buffer.from(buffer).toString('base64url');
}
function base64url2ab(str: string): ArrayBuffer {
return Buffer.from(str, 'base64url');
}
Now you have the foundation. A full implementation would include key exchange between multiple parties, group messaging (with key distribution), and possibly a ratchet mechanism like the Signal Protocol for perfect forward secrecy. But starting with simple ECDH + AES‑GCM is the right first step.
I encourage you to build this yourself. Try to break it – send a tampered ciphertext and see how AES‑GCM rejects it. Rotate your keys and verify that old messages remain readable only with the old private key. Then extend it with push notifications, offline support, and a web client that runs entirely in the browser using the same Web Crypto API.
If you found this guide useful, please like this article, share it with a developer who still thinks HTTPS is enough, and leave a comment about your own experience with cryptography. I read every response, and your insights often teach me something I missed.
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