js

Build an End-to-End Encrypted Messaging API in Node.js with Web Crypto

Learn to build a Node.js end-to-end encrypted messaging API with Web Crypto, ECDH, HKDF, and AES-GCM—no dependencies required.

Build an End-to-End Encrypted Messaging API in Node.js with Web Crypto

I recently found myself wondering why so many Node.js developers treat encryption as a black box. We install libraries like crypto-js or forge without ever asking what happens inside. Then I read about Signal’s protocol and realised that the Web Crypto API, sitting right inside Node.js since version 20, can do everything we need for end‑to‑end encryption without a single external dependency. That journey turned into this article – a practical walkthrough of building your own encrypted messaging API using only the tools that ship with Node.

By the time you finish reading, you will understand how ECDH key exchange works, why AES‑GCM is the gold standard for authenticated encryption, and how to glue them together with HKDF. And because this is a real API, you will also see code that you can run, modify, and break on purpose to see what happens.

Let’s start with the most honest question: why should I care about end‑to‑end encryption? Imagine you run a chat app. If you only encrypt data in transit with TLS, your server holds the plaintext. If an attacker gets into your database, every message is readable. With E2EE, the server never sees plaintext – it only stores gibberish. The only people who can read a message are the sender and the intended receiver. That changes the trust model completely.

The cryptographic recipe I will use has three ingredients. First, Alice and Bob each create a key pair using Elliptic Curve Diffie‑Hellman (ECDH) on the P‑256 curve. They send each other their public keys – over an unsafe channel, it does not matter – and both independently compute the same shared secret. Second, they run that secret through HKDF to turn it into a proper 256‑bit AES key. Third, they encrypt messages with AES in GCM mode, which provides both secrecy and integrity.

I like to visualise this as a chain: key agreement (ECDH) → key derivation (HKDF) → authenticated encryption (AES‑GCM). Break any link and the system fails. Get every link right and you have something that would take a government‑level actor years to break.

Now let’s build it. I will assume you have Node.js 20 or later (that’s where globalThis.crypto.subtle is available without import). We will use TypeScript because type safety helps avoid cryptographic pitfalls.

Create a directory and initialise a project:

mkdir e2e-messenger && cd e2e-messenger
npm init -y
npm install express
npm install -D typescript @types/node @types/express ts-node nodemon
npx tsc --init

Set tsconfig.json to target ES2022 with strict: true.

The first piece of code generates an ECDH key pair and exports the public key as a JSON Web Key (JWK). A JWK is just a structured JSON object that can be safely sent over HTTP.

const { subtle } = globalThis.crypto;

async function generateUserKeys(userId: string) {
  const keyPair = await subtle.generateKey(
    { name: 'ECDH', namedCurve: 'P-256' },
    true,
    ['deriveKey', 'deriveBits']
  );
  const publicKeyJwk = await subtle.exportKey('jwk', keyPair.publicKey);
  return { userId, privateKey: keyPair.privateKey, publicKeyJwk };
}

Now imagine two users: Alice and Bob. Alice generates her keys and sends her publicKeyJwk to Bob. Bob does the same. To derive the shared secret, each party must import the other’s public key from JWK, then call subtle.deriveBits with their own private key.

async function deriveSharedSecret(
  myPrivateKey: CryptoKey,
  peerPublicKeyJwk: JsonWebKey
): Promise<ArrayBuffer> {
  const peerPublicKey = await subtle.importKey(
    'jwk',
    peerPublicKeyJwk,
    { name: 'ECDH', namedCurve: 'P-256' },
    false,
    []
  );
  return subtle.deriveBits(
    { name: 'ECDH', public: peerPublicKey },
    myPrivateKey,
    256
  );
}

The result is an ArrayBuffer of 256 bits – the raw shared secret. But that secret is not yet safe to use directly as an AES key. ECDH output can have biases. That is where HKDF comes in.

Using subtle.deriveKey with HKDF‑SHA256, you can stretch the raw secret into a uniformly random 256‑bit AES key. You also need a salt (which can be a random 16‑byte value) and some context info (like the user IDs).

async function deriveAESKey(sharedSecret: ArrayBuffer, salt: Uint8Array, info: Uint8Array) {
  return subtle.deriveKey(
    {
      name: 'HKDF',
      hash: 'SHA-256',
      salt,
      info,
    },
    sharedSecret,
    { name: 'AES-GCM', length: 256 },
    false,
    ['encrypt', 'decrypt']
  );
}

Now we have a ready‑to‑use AES key. Encrypting a message with AES‑GCM requires a unique Initialization Vector (IV) every time. The IV can be 12 bytes – secure random bytes from crypto.getRandomValues.

function encryptMessage(plaintext: string, key: CryptoKey) {
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const encoder = new TextEncoder();
  const encoded = encoder.encode(plaintext);
  return subtle.encrypt({ name: 'AES-GCM', iv }, key, encoded).then(ciphertext => {
    // The ciphertext returned by AES-GCM includes the authentication tag appended to the data
    return { iv: Buffer.from(iv).toString('base64'), ciphertext: Buffer.from(ciphertext).toString('base64') };
  });
}

Decryption is the reverse: import the iv and ciphertext from base64, and call subtle.decrypt. If the authentication tag is wrong because the data was tampered with, decrypt throws an error. That is your integrity guarantee.

Now let’s wire this into an Express API. I will keep it simple with an in‑memory store. Each user has a public key, and each pair of users has a derived session key.

// In-memory store
const users: Record<string, { privateKey: CryptoKey; publicKeyJwk: JsonWebKey }> = {};
const sessions: Record<string, CryptoKey> = {};

app.post('/register', async (req, res) => {
  const { userId } = req.body;
  const keys = await generateUserKeys(userId);
  users[userId] = { privateKey: keys.privateKey, publicKeyJwk: keys.publicKeyJwk };
  res.json({ publicKey: keys.publicKeyJwk });
});

app.post('/exchange', async (req, res) => {
  const { userId, peerId, peerPublicKeyJwk } = req.body;
  const user = users[userId];
  if (!user) return res.status(404).json({ error: 'User not found' });
  const sharedSecret = await deriveSharedSecret(user.privateKey, peerPublicKeyJwk);
  const salt = crypto.getRandomValues(new Uint8Array(16));
  const info = new TextEncoder().encode(`${userId}-${peerId}`);
  const aesKey = await deriveAESKey(sharedSecret, salt, info);
  const sessionId = `${userId}:${peerId}:${Buffer.from(salt).toString('hex')}`;
  sessions[sessionId] = aesKey;
  res.json({ sessionId, salt: Buffer.from(salt).toString('base64') });
});

Of course, in a real system you would store the session key securely and never expose the shared secret. But this demonstrates the flow.

Once a session key exists, sending an encrypted message is straightforward:

app.post('/send', async (req, res) => {
  const { sessionId, plaintext } = req.body;
  const key = sessions[sessionId];
  if (!key) return res.status(400).json({ error: 'Session not found' });
  const { iv, ciphertext } = await encryptMessage(plaintext, key);
  // In production you would store this ciphertext persistently
  res.json({ iv, ciphertext });
});

The recipient retrieves the iv and ciphertext, calls decryptMessage with the same session key, and gets back the original text. If any byte of the ciphertext or IV changes during transit, decryption throws an error.

Now you have a working, zero‑dependency E2EE messaging API. But building it is only half the story. The hard part is getting the details right.

What happens if you accidentally reuse an IV with the same key? AES‑GCM becomes completely insecure – an attacker can recover the keystream. That is why I generate a fresh 12‑byte random IV for every message. Never use a counter or a timestamp.

Another trap: exporting and importing keys. Make sure you use the same curve and key usages. The JWK export must specify key_ops if you plan to use it for derivation.

Should you generate a new ECDH key pair for each session? For most apps, yes. Key rotation limits the damage if a private key is leaked. Signal does it per‑message (the Double Ratchet). For a simpler API, a per‑session key pair is acceptable.

What about storing private keys in the server for a web client? That defeats the purpose of E2EE – the server would hold the private key. In a real product, private keys live in the browser’s IndexedDB or on the user’s device. My API example assumes the server is only a relay; the actual key material never leaves the clients. The register endpoint is fake – in production, the client generates its own keys and sends only the public part.

Let me share a mistake I made when I first implemented this. I forgot to include the authentication tag when concatenating the ciphertext. In Node’s Web Crypto API, encrypt returns the ciphertext with the tag appended (the last 16 bytes). When I decoded it, I accidentally dropped the tag. Decryption kept failing silently. Debugging that took two hours. The fix is to treat the entire output as one blob and let decrypt handle splitting.

If you want to test the API locally, start your server with npm run dev and use curl or a tool like Postman. Register two users, exchange keys, then send a message. Try modifying the ciphertext before sending – the decryption will throw, and you will see that tampering is impossible unnoticed.

This is the kind of control that using the Web Crypto API directly gives you. You are not hiding behind a library that might roll its own crypto. You are using the same primitives that browsers and the operating system provide, audited by thousands of cryptographers.

I hope this walkthrough demystifies the process. The next time someone asks why we need end‑to‑end encryption, you can point to this code and say “because anything else means trusting the server, and we don’t have to.”

If you found this useful, please like the article, share it with a developer friend, and leave a comment telling me what you would build with these building blocks. Your feedback helps me decide what to write about next.


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: Node.js Web Crypto, end-to-end encryption, ECDH key exchange, HKDF, AES-GCM



Similar Posts
Blog Image
Production-Ready Event-Driven Microservices: NestJS, RabbitMQ, and MongoDB Architecture Guide

Learn to build production-ready microservices with NestJS, RabbitMQ & MongoDB. Master event-driven architecture, async messaging & distributed systems.

Blog Image
Build a Type-Safe GraphQL API with NestJS, Prisma and Code-First Schema Generation Tutorial

Learn to build a type-safe GraphQL API using NestJS, Prisma & code-first schema generation. Complete guide with authentication, testing & deployment.

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 High-Performance GraphQL APIs: NestJS, Prisma & Redis Caching Guide

Learn to build a high-performance GraphQL API with NestJS, Prisma, and Redis caching. Master database operations, solve N+1 problems, and implement authentication with optimization techniques.

Blog Image
Complete Guide to Integrating Svelte with Tailwind CSS for Modern Component Development

Learn to integrate Svelte with Tailwind CSS for efficient component styling. Build modern web interfaces with utility-first design and reactive components.

Blog Image
Build Type-Safe Event-Driven Architecture: TypeScript, RabbitMQ & Domain Events Tutorial

Learn to build scalable, type-safe event-driven architecture using TypeScript, RabbitMQ & domain events. Master CQRS, event sourcing & reliable messaging patterns.