I remember the exact moment I realized how fragile digital secrets really were. I had just finished building a simple chat application for a friend’s birthday group. Everyone loved the emojis, the read receipts, the typing indicators. Then my friend asked: “If the database gets hacked, are our messages safe?” I opened my mouth to say “of course,” but the words died in my throat. The truth was that all messages were stored as plain text in PostgreSQL. Any attacker who broke in could read every inside joke, every secret wish, every whispered goodbye. That night I started learning about encryption. Not the textbook kind I skimmed in university, but the real, practical kind that protects messages when the server itself is compromised.
You might be wondering: why not just use HTTPS and call it a day? HTTPS encrypts data in transit, but at rest—on the server’s disk—messages are completely exposed. End-to-end encryption is the only way to ensure that even the server operator cannot read your data. The server becomes a dumb mailbox: it stores encrypted blobs, but only the intended recipient (or the owner) holds the key.
So I decided to build a secure messaging API in Node.js, using nothing but the Web Crypto API that ships with every modern runtime. No crypto-js, no node-forge, just globalThis.crypto. The chosen algorithm was AES-256-GCM, because it provides both confidentiality and authentication in one shot. If an attacker tampers with the ciphertext, the decryption fails immediately. No padding, no oracle attacks. And I would derive the encryption key from the user’s password using HKDF, not a simple hash.
Let’s walk through the architecture first. When a user registers, we generate two salts: one for bcrypt (to verify the password for login) and a separate 32‑byte random salt for key derivation. The password plus the key salt go through PBKDF2 to produce 256 bits of key material. Then we feed that key material into HKDF with an info string like "message-encryption-key" to derive the final AES-256-GCM key. That derived key is never stored anywhere. The server only remembers the salts. When the user logs in and sends a message, we repeat the derivation with their password (from the JWT payload or a session) and the stored salt. Then we generate a fresh 12‑byte IV for each message, encrypt, and store the IV + ciphertext as a Base64‑encoded JSON blob.
Why two separate derivations? Because bcrypt is great at resisting brute force but is slow and produces a fixed‑length output that is not suitable as an AES key. PBKDF2 gives us a uniform‑looking key material, and HKDF makes sure that different keys for different purposes (encryption vs. authentication) are cryptographically independent.
Here’s the core encryption function I wrote in crypto.service.ts:
import { Buffer } from 'buffer';
const encoder = new TextEncoder();
export async function encryptMessage(
password: string,
keySalt: Uint8Array,
plaintext: string
): Promise<{ iv: string; ciphertext: string }> {
const keyMaterial = await crypto.subtle.importKey(
'raw',
encoder.encode(password),
'PBKDF2',
false,
['deriveKey']
);
const aesKey = await crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: keySalt,
iterations: 310000,
hash: 'SHA-256',
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt']
);
const iv = crypto.getRandomValues(new Uint8Array(12));
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
aesKey,
encoder.encode(plaintext)
);
return {
iv: Buffer.from(iv).toString('base64'),
ciphertext: Buffer.from(encrypted).toString('base64'),
};
}
Notice the iterations: 310000 – that matches OWASP’s 2023 recommendation for PBKDF2. This makes brute‑force attacks painfully slow. Also note that I never store the derived AES key; it exists only in memory during the encryption call. Even if the server process is dumped, the key is gone after the CPU cycles complete.
Decryption is the mirror image, but with one critical difference: you must check that the authentication tag (appended to ciphertext by AES-GCM) is valid. The Web Crypto API does that automatically. If the tag is wrong, decrypt throws an error. That’s your only defense against tampering.
Now, the real world has sharp edges. One common mistake is reusing an IV with the same key. With AES‑GCM, that can leak the GHASH authentication key and completely break the encryption. So I always call crypto.getRandomValues() for every message. Another pitfall is storing the IV in the clear alongside the ciphertext. That’s fine – the IV doesn’t need to be secret, only unique. But you must never derive the key from the password every time without a unique salt. That’s why each user gets a random keySalt at registration.
Let’s talk about the bigger picture. Once I had the encryption service, I built an Express.js endpoint. When a user sends a POST to /messages, the middleware extracts the user ID and the raw password from the JWT (yes, the password travels inside the token – but only inside the server’s memory, never logged). Then the encryption service derives the key and encrypts. The encrypted payload is stored in PostgreSQL via Prisma. When the user fetches their messages, the decryption service repeats the derivation and decodes the ciphertext.
Have you ever stopped to think what happens if the user forgets their password? Traditional end‑to‑end systems lose access forever. In my design, the server could offer a recovery key that is itself encrypted with a separate key derived from an email‑based reset flow. But for now, I chose the honest path: if you lose your password, you lose your messages.
After finishing the first version, I ran a penetration test. I purposely leaked the database dump. An attacker could see the encryptedPayload columns: {"iv":"A5x...","ciphertext":"6Jh..."}. Without the password, it’s gibberish. Even I, the developer, could not recover the plaintext unless I knew the user’s password. That gave me the peace I had been missing.
If you are building anything that stores user secrets, stop treating your database as a trusted vault. Use the built‑in crypto APIs. They are fast, well‑audited, and free. And yes, the Web Crypto API works both in Node.js and in the browser, which opens the door to client‑side encryption where the server never sees plaintext at all.
I’d love to hear what you built. Did you use a different algorithm? Did you run into an IV reuse bug? Drop a comment below. If this article helped you understand symmetric encryption a little better, share it with a colleague who always said “I’ll add encryption later.” And if you want more of these deep, practical walkthroughs, hit that like button – it tells me you care about writing code that is both correct and secure.
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