I’ve been thinking a lot about security lately. Not just passwords and firewalls, but the kind of security that protects a secret even from the person holding the vault. This is the promise of end-to-end encryption. It’s what lets you send a message with the confidence that only the intended recipient can ever read it. Today, I want to show you how to build that promise into a Node.js API. We’ll use the powerful tools already built into your runtime, moving beyond basic authentication to create true message secrecy.
Why focus on this now? In a world where data breaches are common, the responsibility on developers to protect user data has never been greater. Relying solely on transport security (HTTPS) or hoping your database is never compromised is no longer sufficient. We need to design systems where a breach doesn’t mean exposed secrets. This is that design.
Let’s start with a fundamental question: how do you send a secret to someone if you’ve never met to exchange a code? You need a system that doesn’t require a pre-shared secret. This is where two types of encryption come into play.
Symmetric encryption, like AES, is fast and strong. It uses one key to lock and unlock the data. But you have to get that key to the other person securely. How do you do that without already having a secure channel? Asymmetric encryption, like RSA, solves this. It uses a pair of keys: a public one to lock, and a private one to unlock. You can share your public key openly. Anyone can use it to encrypt a message, but only you, with your private key, can decrypt it.
The problem? Asymmetric encryption is slow for large amounts of data. The elegant solution, and the one used by virtually every secure system today, is a hybrid approach. We use asymmetric encryption to safely deliver a one-time symmetric key. Then, we use that fast symmetric key to encrypt the actual message. It’s the best of both worlds.
Node.js gives us everything we need for this in the crypto.subtle interface, part of the Web Crypto API. It’s standard, powerful, and requires no extra libraries. Let’s see it in action. First, how do we generate the key pair for a user?
// Generating an RSA key pair for secure key exchange
async function generateUserKeyPair() {
const keyPair = await crypto.subtle.generateKey(
{
name: "RSA-OAEP",
modulusLength: 4096,
publicExponent: new Uint8Array([1, 0, 1]),
hash: "SHA-256",
},
true, // The key is extractable so we can store or transmit it
["encrypt", "decrypt"]
);
return keyPair;
}
This creates a strong RSA key pair. The public key can be shared with anyone who wants to send you a message. But what are they actually sending? They’ll send a wrapped AES key. Think of it like sending a locked box containing the real key. Here’s how a sender creates a fresh AES key and “wraps” it using the recipient’s public RSA key.
// Sender: Create a message key and wrap it for the recipient
async function prepareMessageFor(recipientPublicKey, text) {
// 1. Make a fresh AES key for this message
const messageKey = await crypto.subtle.generateKey(
{ name: "AES-GCM", length: 256 },
true,
["encrypt", "decrypt"]
);
// 2. Encrypt the message with AES
const iv = crypto.getRandomValues(new Uint8Array(12));
const encryptedData = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: iv },
messageKey,
new TextEncoder().encode(text)
);
// 3. Wrap the AES key with the recipient's public RSA key
const wrappedKey = await crypto.subtle.wrapKey(
"raw",
messageKey,
recipientPublicKey,
{ name: "RSA-OAEP" }
);
return {
ciphertext: bufferToBase64(encryptedData),
iv: bufferToBase64(iv),
wrappedKey: bufferToBase64(wrappedKey),
};
}
Notice something important? We generate a brand new AES key for every single message. This is good practice. It limits the amount of data encrypted under any one key. The iv, or initialization vector, is also random and unique for each operation. Never reuse an IV with the same key.
Now, the recipient gets this package. They have the private RSA key. They can unwrap the AES key and then decrypt the message.
// Recipient: Unwrap the key and decrypt the message
async function decryptMessage(privateKey, wrappedKey, iv, ciphertext) {
// 1. Unwrap the AES key using the private RSA key
const messageKey = await crypto.subtle.unwrapKey(
"raw",
base64ToBuffer(wrappedKey),
privateKey,
{ name: "RSA-OAEP" },
{ name: "AES-GCM" },
true,
["decrypt"]
);
// 2. Decrypt the message with the unwrapped AES key
const decryptedData = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: base64ToBuffer(iv) },
messageKey,
base64ToBuffer(ciphertext)
);
return new TextDecoder().decode(decryptedData);
}
This gives us confidentiality. But how does the recipient know the message really came from the claimed sender? This is where integrity and authentication come in. We need a digital signature. For this, we use a different algorithm, like ECDSA, with a separate key pair. The sender signs the message with their private signing key, and the recipient verifies it with the sender’s public signing key.
Have you considered what happens if someone intercepts an old message and sends it again? This is called a replay attack. A simple defense is to include a timestamp within the signed data. If the timestamp is too old, you reject the message.
Building this into an API requires careful design. You need endpoints to publish public keys, to send these encrypted packages, and to retrieve them. Keys must be stored securely; private keys should never leave their generation context if possible, often requiring a key management service in production.
The beauty of this approach is in its simplicity. You don’t need complex external libraries. The standards are robust and well-tested. By combining RSA for key exchange, AES for bulk data encryption, and ECDSA for signatures, you build a layered defense. Each part has a specific job.
What does this mean for your application’s architecture? It shifts the security model. Your server becomes a facilitator for encrypted blobs, not a reader of messages. It can validate metadata and signatures without ever accessing the plaintext content. This significantly reduces your risk and your liability.
Implementing this changes how you think about data. You start to see messages as sealed envelopes, not postcards. The process might seem involved at first, but each step is logical and builds towards a single goal: user trust.
I encourage you to take this foundation and experiment with it. Build a simple messaging endpoint. Think about key storage and rotation. The path to stronger security starts with understanding these basic blocks. If you found this walkthrough helpful, please share it with another developer. Have you implemented something similar? What challenges did you face? Let me know in the comments.
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