How to Implement End-to-End Encryption in Node.js with the Web Crypto API
Learn how to build end-to-end encryption in Node.js using the Web Crypto API, AES-GCM, and RSA-OAEP—no libraries required. Start now.
I was building a simple chat application when I hit a wall. The server could see every message. TLS protected it during transit, but once the data landed on my database, I could read it all. That felt wrong. If I could read it, so could anyone with access to that server. So I started digging into end‑to‑end encryption. This article is the result of that research — a practical guide to implementing E2EE in a Node.js API using nothing but the Web Crypto API that ships with Node 18+. No third‑party libraries, no magic. Just raw cryptography, code, and a dose of paranoia.
You might ask: wasn’t TLS enough? TLS encrypts the connection between two points. But it doesn’t protect the data once it reaches the endpoint. If you’re sending a message to a server that then forwards it to another client, the server holds the plaintext. With E2EE, only the sender and the intended recipient can decrypt the payload. The server becomes a blind relay. It sees only ciphertext and wrapped keys. That is the goal.
Let’s start with the building blocks. I will use Node’s built‑in crypto.subtle — the same API available in browsers. This means the same cryptographic primitives work on both the front‑end and the back‑end. If you’ve used the Web Crypto API in a browser context, you already know half of what follows.
The trusted combination: AES‑GCM + RSA‑OAEP
I chose AES‑GCM for symmetric encryption because it gives you both confidentiality and integrity in one operation. You feed it a plaintext and a key, and it outputs a ciphertext and an authentication tag. If anyone tampers with the ciphertext, the tag will not match when you decrypt.
RSA‑OAEP is used for key wrapping. You never encrypt the whole message with RSA — that would be slow and limited in size. Instead, you generate a fresh AES key for each message, encrypt the message with that AES key, and then wrap (encrypt) the AES key with the recipient’s RSA public key. Only the recipient’s private key can unwrap the AES key and then decrypt the message.
I will show you the core helpers first.
Helper functions
// src/crypto/utils.ts
export function bufferToBase64(buffer: ArrayBuffer): string {
return Buffer.from(buffer).toString("base64");
}
export function base64ToBuffer(base64: string): ArrayBuffer {
return Buffer.from(base64, "base64").buffer;
}
export function stringToBuffer(text: string): ArrayBuffer {
return new TextEncoder().encode(text).buffer;
}
export function bufferToString(buffer: ArrayBuffer): string {
return new TextDecoder().decode(buffer);
}
Use crypto.getRandomValues() for randomness. Never touch Math.random() in a cryptographic context.
AES‑GCM encryption
The Web Crypto API separates key generation from encryption. You first generate a symmetric key.
// src/crypto/aes.ts
export async function generateAESKey(): Promise<CryptoKey> {
return crypto.subtle.generateKey(
{ name: "AES-GCM", length: 256 },
true, // extractable: we need to export or wrap this key
["encrypt", "decrypt"]
);
}
export async function encryptAES(
plaintext: string,
key: CryptoKey
): Promise<{ ciphertext: string; iv: string }> {
const iv = crypto.getRandomValues(new Uint8Array(12)); // 96 bits
const encoded = new TextEncoder().encode(plaintext);
const encrypted = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv, tagLength: 128 },
key,
encoded
);
// The encrypted result is ciphertext + authentication tag appended
return {
ciphertext: bufferToBase64(encrypted),
iv: bufferToBase64(iv.buffer),
};
}
export async function decryptAES(
ciphertext: string,
iv: string,
key: CryptoKey
): Promise<string> {
const decrypted = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: base64ToBuffer(iv), tagLength: 128 },
key,
base64ToBuffer(ciphertext)
);
return new TextDecoder().decode(decrypted);
}
Notice that the authentication tag is automatically appended to the ciphertext by the Web Crypto API. When you decrypt, it verifies the tag. If tampering occurred, the promise rejects with an error. You never need to store the tag separately.
RSA‑OAEP key wrapping
Now we need a way to hand the AES key to the recipient. I generate an RSA key pair for each user. The public key is stored on the server, the private key stays on the client (or is derived from a password using PBKDF2, but that is a separate topic).
// src/crypto/rsa.ts
export async function generateRSAKeyPair(): Promise<CryptoKeyPair> {
return crypto.subtle.generateKey(
{
name: "RSA-OAEP",
modulusLength: 4096,
publicExponent: new Uint8Array([1, 0, 1]),
hash: "SHA-256",
},
true,
["wrapKey", "unwrapKey"]
);
}
/**
* Wraps (encrypts) a symmetric key using RSA-OAEP.
* Returns a base64-encoded wrapped key.
*/
export async function wrapAESKey(
aesKey: CryptoKey,
rsaPublicKey: CryptoKey
): Promise<string> {
const wrapped = await crypto.subtle.wrapKey(
"raw",
aesKey,
rsaPublicKey,
{ name: "RSA-OAEP", hash: "SHA-256" }
);
return bufferToBase64(wrapped);
}
/**
* Unwraps (decrypts) a wrapped symmetric key using RSA-OAEP.
*/
export async function unwrapAESKey(
wrappedKeyBase64: string,
rsaPrivateKey: CryptoKey
): Promise<CryptoKey> {
const wrapped = base64ToBuffer(wrappedKeyBase64);
return crypto.subtle.unwrapKey(
"raw",
wrapped,
rsaPrivateKey,
{ name: "RSA-OAEP", hash: "SHA-256" },
{ name: "AES-GCM", length: 256 },
true,
["encrypt", "decrypt"]
);
}
Putting the pieces together
Imagine Alice wants to send a message to Bob. Here is the sequence.
- Key registration: Bob creates an RSA key pair. He sends his public key to the server. He keeps his private key safe — on his device, never transmitted.
- Message sending (Alice side):
- Alice generates a fresh AES key.
- She encrypts her message with that AES key.
- She fetches Bob’s public key from the server.
- She wraps the AES key using Bob’s RSA public key.
- She sends the ciphertext and the wrapped AES key to the server.
- Server side: The server stores the ciphertext and the wrapped key. It never sees the plaintext or the raw AES key.
- Message receiving (Bob side):
- Bob requests his messages from the server.
- He receives the ciphertext and the wrapped key.
- He unwraps the AES key with his RSA private key.
- He decrypts the ciphertext with the unwrapped AES key.
Let me show you a simplified endpoint for sending a message.
// routes/messages.ts
import { Router } from "express";
import { generateAESKey, encryptAES } from "../crypto/aes";
import { wrapAESKey } from "../crypto/rsa";
import { base64ToBuffer } from "../crypto/utils";
const router = Router();
router.post("/messages", async (req, res) => {
const { recipientId, plaintext } = req.body;
// 1. Fetch recipient's public RSA key from database
const publicKeyBuffer = await getUserPublicKey(recipientId);
const publicKey = await crypto.subtle.importKey(
"spki",
publicKeyBuffer,
{ name: "RSA-OAEP", hash: "SHA-256" },
true,
["wrapKey"]
);
// 2. Generate AES key
const aesKey = await generateAESKey();
// 3. Encrypt the message
const { ciphertext, iv } = await encryptAES(plaintext, aesKey);
// 4. Wrap the AES key with recipient's RSA public key
const wrappedKey = await wrapAESKey(aesKey, publicKey);
// 5. Store ciphertext + wrapped key (and iv) in database
await storeEncryptedMessage(
req.user.id,
recipientId,
ciphertext,
iv,
wrappedKey
);
res.status(201).json({ status: "sent" });
});
On the receiving end, Bob’s client does:
// Inside the client (or a dedicated decrypt endpoint that runs client‑side)
const { ciphertext, iv, wrappedKey } = message;
const aesKey = await unwrapAESKey(wrappedKey, bobPrivateKey);
const plaintext = await decryptAES(ciphertext, iv, aesKey);
Where should the RSA private key live?
This is the million‑dollar question. If the private key is stored on the server, the server can decrypt everything. True E2EE requires the private key to be held exclusively by the client. Options:
- Derive it from a password using PBKDF2 or Argon2. The server never sees the password, only a verification hash.
- Store it in secure hardware (TPM, Secure Enclave) on the user’s device.
- Use a third‑party key management service like AWS KMS, but only if you trust the provider.
For a quick demo, you can store the private key in a client‑side local storage or IndexedDB — but warn users that their encryption dies if they clear their browser data.
Common pitfalls I ran into
- IV reuse: AES‑GCM with the same key and IV is catastrophic. Always generate a fresh random IV for each encryption.
- Key extraction: Marking the key as
extractable: trueis necessary for wrapping and exporting. In production you might avoid exporting raw keys at all and only use wrapping. - Timing attacks: The Web Crypto API is implemented in native C++ and is constant‑time for most operations. Still, avoid comparing authentication tags in user space.
- Wrapped key size: RSA‑OAEP with a 4096‑bit key produces a wrapped key of about 512 bytes. That is fine for most databases but be aware.
I hit all of these during my first implementation. Debugging a silent decryption failure because of a wrong IV length took me an entire afternoon.
Is the server completely blind?
Almost. Even though the server cannot read the messages, it can still observe metadata: who is talking to whom, when, and how often. Metadata alone can reveal a lot. If you need to hide that too, you would need onion routing or private information retrieval. That is a separate deep rabbit hole.
So where does this leave us? You now have a working, no‑frills E2EE layer for a Node.js API. It uses the same cryptographic primitives as Signal, WhatsApp, and many secure systems. The key takeaway is that the server does not need to see the data to route it. By keeping the private keys client‑side and using hybrid encryption, you can build a messaging system that your own database cannot compromise.
I hope this practical demonstration helps you think differently about data ownership. If you found it useful, leave a comment below with your biggest security headache — I read every one. And consider sharing it with a colleague who still thinks TLS is enough.
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