js

Build a Secure Messaging API in Node.js with AES-256-GCM and Web Crypto

Learn to build a secure Node.js messaging API with AES-256-GCM, PBKDF2, and Web Crypto to protect data at rest. Read the guide now.

Build a Secure Messaging API in Node.js with AES-256-GCM and Web Crypto

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

Keywords: Node.js security, AES-256-GCM, Web Crypto API, end-to-end encryption, secure messaging API



Similar Posts
Blog Image
Complete Guide to Integrating Next.js with Prisma ORM for Type-Safe Full-Stack Applications

Learn how to integrate Next.js with Prisma ORM for type-safe database operations and seamless full-stack development. Get step-by-step setup guide now!

Blog Image
Using Zustand with Remix for Safe Client State Management

Learn how to use Zustand with Remix to manage client-only state safely, avoid hydration mismatches, and build faster UX patterns.

Blog Image
Complete Guide: Building Type-Safe APIs with tRPC, Prisma, and Next.js in 2024

Learn to build type-safe APIs with tRPC, Prisma, and Next.js. Complete guide covering setup, authentication, deployment, and best practices for modern web development.

Blog Image
How Zod Solves TypeScript’s Biggest Runtime Safety Problem

Discover how Zod brings runtime validation to TypeScript, eliminating bugs from untrusted data and simplifying your codebase.

Blog Image
Complete Guide to Integrating Next.js with Prisma ORM: Build Type-Safe Full-Stack Applications

Learn how to integrate Next.js with Prisma ORM for type-safe database operations. Build full-stack apps with seamless data management and TypeScript support.

Blog Image
Complete Guide to Vue.js Pinia Integration: Master Modern State Management in 2024

Learn how to integrate Vue.js with Pinia for efficient state management. Master modern store-based architecture, improve app performance, and streamline development.