LEVEL 4 · LESSON 5 OF 13

Node.js Core

Cryptography with node:crypto

Use node:crypto correctly: unguessable tokens, hashes and HMAC webhook signatures, timing-safe comparison, scrypt password hashing and AES-256-GCM encryption.

  • 55 min to read and try
  • You need: Files, paths and your computer, Streams and buffers, and Events, processes and workers
  • You build: An account security toolkit that hashes passwords, issues reset tokens, verifies signed payment webhooks and encrypts bank account numbers
Test yourself

BY THE END OF THIS LESSON YOU CAN

  • Choose between a hash, an HMAC, a password hash and encryption for a given security goal
  • Generate unguessable tokens and codes and store only their hashes
  • Verify an HMAC-signed webhook over the raw body with timingSafeEqual and reject replays
  • Hash and verify passwords with scrypt, a random salt and a self-describing stored format
  • Encrypt and decrypt with AES-256-GCM, bind data with AAD and detect tampering

A shop with four secrets to protect

Your shop's server holds things attackers want. Think about what happens when something goes wrong:

  1. Passwords. One day a backup of the users table leaks. If the passwords are readable, or easy to recover, every customer who reuses a password is exposed on every other site too.
  2. Payment notifications. The payment provider calls your server with "charge.success, ₦45,000, order 1042", and your code marks the order paid and ships it. Anyone on the internet can send the same request. How do you know it came from the provider?
  3. Bank account numbers. To pay out sellers you must store their account numbers and read them back later. A leaked backup must not reveal them.
  4. Tokens. A password-reset link contains a token. If an attacker can guess tokens, they can take over accounts without knowing any password.

Each problem needs a different cryptographic tool, and using the wrong one is a classic, serious bug: passwords stored with a fast hash, webhook signatures compared with ===, "encryption" that is really base64. Files, paths and your computer introduced randomUUID, randomBytes and SHA-256. This lesson goes through every tool in node:crypto that a backend developer needs, shows how each one fails when misused, and ends with a small toolkit that handles all four problems.

Almost every example needs Node.js; the browser version of crypto is different (you will meet it at the end). Run them with node file.js.

The words and the map

A few terms come up again and again:

  • A key is a secret random value, usually 32 bytes, known only to the parties that should be able to read or sign something.
  • A hash function turns any input into a fixed-length fingerprint (a digest). It has no key and cannot be reversed.
  • A MAC (message authentication code) is a fingerprint made with a key. Only someone with the key can make a valid one. HMAC is the standard way to build one from a hash.
  • A cipher encrypts: it turns plaintext into ciphertext with a key, and back again.
  • A salt and a nonce (also called IV, initialisation vector) are random values that are not secret. They make every result unique even when the input repeats.

And the map you will fill in during this lesson:

You need to…UseNot
make an id, token or code nobody can guessrandomBytes, randomInt, randomUUIDMath.random()
check a file or value has not changed by accidentSHA-256 hashMD5, SHA-1
prove a message came from someone who knows a shared keyHMAC-SHA256, compared with timingSafeEqualsha256(secret + message), ===
store a passwordscrypt or Argon2id with a random saltSHA-256, encryption
store data you must read back laterAES-256-GCM with a random noncebase64, hashing

Cryptographic functions work on bytes, so their results are Buffers (from Streams and buffers). To store or send bytes as text you encode them. Encoding is not encryption: anyone can decode it.

encodings.jsNode.js only
import { randomBytes } from "node:crypto";

const key = randomBytes(32);
console.log("bytes:     ", key.length);
console.log("hex:       ", key.toString("hex").length, "characters");
console.log("base64:    ", key.toString("base64").length, "characters");
console.log("base64url: ", key.toString("base64url").length, "characters");

const secretNote = Buffer.from("PIN 4821").toString("base64");
console.log("'encrypted' with base64:", secretNote, "->", Buffer.from(secretNote, "base64").toString());
Output of node encodings.js
bytes:      32
hex:        64 characters
base64:     44 characters
base64url:  43 characters
'encrypted' with base64: UElOIDQ4MjE= -> PIN 4821

base64url is base64 with - and _ instead of + and /, and no = padding, so it can go into URLs and file names unchanged. It is the right choice for tokens in links.

Unguessable values

Math.random() is built for games and simulations. Its numbers come from a formula with a small internal state, and after seeing enough outputs an attacker can predict the next ones. node:crypto's functions take randomness from the operating system's cryptographically secure generator, which is designed so that no amount of past output helps predict the future.

Codes and dice: randomInt

A six-digit code for confirming a phone number looks easy: take a random byte and use % 10 for each digit. But that is subtly unfair. There are 256 possible byte values, and 256 is not a multiple of 10:

modulo-bias.js
const counts = Array(10).fill(0);
for (let byte = 0; byte < 256; byte++) counts[byte % 10]++;
console.log(counts.map((count, digit) => `${digit}:${count}`).join(" "));
Output of node modulo-bias.js and of the browser terminal
0:26 1:26 2:26 3:26 4:26 5:26 6:25 7:25 8:25 9:25

Digits 0 to 5 come up 26 times in 256, digits 6 to 9 only 25 times. This modulo bias makes some codes more likely than others, which helps an attacker who is guessing. randomInt(min, max) avoids it (it throws away values that would cause bias and draws again), and returns a whole number from min up to but not including max:

codes.jsNode.js only
import { randomInt, randomUUID, randomBytes } from "node:crypto";

const code = String(randomInt(0, 1_000_000)).padStart(6, "0");
console.log("six digits:", /^\d{6}$/.test(code));

const die = randomInt(1, 7);
console.log("die roll between 1 and 6:", die >= 1 && die <= 6);

const orderId = randomUUID();
console.log("uuid v4:", /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(orderId));

const resetToken = randomBytes(32).toString("base64url");
console.log("reset token length:", resetToken.length, "url-safe:", /^[A-Za-z0-9_-]+$/.test(resetToken));
Output of node codes.js
six digits: true
die roll between 1 and 6: true
uuid v4: true
reset token length: 43 url-safe: true
  • A 6-digit code has only 1,000,000 possibilities. That is fine for a code that expires in 10 minutes and allows 5 attempts, and hopeless for anything long-lived. Rate limiting is what makes short codes safe.
  • A UUID v4 has 122 random bits. Good for ids, but ids often appear in URLs and logs, so do not use them as secrets.
  • 32 random bytes are 256 bits: guessing is impossible in practice. Use this size for session ids, API keys and reset tokens.

Store the hash of a token, not the token

If your database holds valid reset tokens and it leaks, the attacker can use them. So store only a SHA-256 hash of each token. The user's e-mail link holds the token itself; when it comes back, you hash it again and look the hash up.

reset-token.jsNode.js only
import { createHash, randomBytes } from "node:crypto";

const sha256 = (text) => createHash("sha256").update(text).digest("hex");
const resets = new Map();

function createReset(userId, now) {
  const token = randomBytes(32).toString("base64url");
  resets.set(sha256(token), { userId, expiresAt: now + 15 * 60_000 });
  return token;
}

function useReset(token, now) {
  const key = sha256(token);
  const entry = resets.get(key);
  resets.delete(key);
  if (!entry || entry.expiresAt < now) return null;
  return entry.userId;
}

const now = Date.UTC(2026, 8, 24, 9, 0);
const link = createReset("user-17", now);
console.log("stored values contain the token:", [...resets.keys()].includes(link));
console.log("first use:", useReset(link, now + 60_000));
console.log("second use:", useReset(link, now + 120_000));
console.log("expired:", useReset(createReset("user-18", now), now + 16 * 60_000));
Output of node reset-token.js
stored values contain the token: false
first use: user-17
second use: null
expired: null

A fast hash like SHA-256 is fine here, even though you will soon see it is wrong for passwords. The difference is the input: a token has 256 random bits, so there is nothing to guess, however fast the hash. A password chosen by a person might be one of a few million common ones. The token is also deleted on first use and expires, so a stolen link stops working quickly.

Hashes in depth

A cryptographic hash promises three things: the same input always gives the same digest; you cannot find an input for a given digest (it is one-way); and you cannot find two inputs with the same digest (it is collision resistant). A tiny change in the input changes about half the output bits, so similar inputs give completely different digests. crypto.hash is a one-call shortcut for small inputs:

hash-props.jsNode.js only
import { hash } from "node:crypto";

const a = hash("sha256", "Invoice INV-1042: ₦45,000");
const b = hash("sha256", "Invoice INV-1042: ₦45,001");

let differentBits = 0;
for (let i = 0; i < a.length; i += 2) {
  let x = parseInt(a.slice(i, i + 2), 16) ^ parseInt(b.slice(i, i + 2), 16);
  while (x) {
    differentBits += x & 1;
    x >>= 1;
  }
}
console.log(a);
console.log(b);
console.log(`${differentBits} of 256 bits differ`);
console.log("sha512 length:", hash("sha512", "x").length, "hex characters");
Output of node hash-props.js
a4f32f85a2a935a8bc1f34ed9ab21972048d0aed0364e0e1819c8b3f17bb20a0
1b3ddaba0434a7ac12c18af7ea870f09f867e288c97f533cdefb73c0ba39cf51
154 of 256 bits differ
sha512 length: 128 hex characters

MD5 and SHA-1 still appear in old code and tutorials. Both are broken: researchers can create two different files with the same MD5 or SHA-1 digest. Never use them for security. SHA-256 and SHA-512 are the standard choices today.

Hashing a file with a stream

Downloads often publish a SHA-256 checksum so you can check the file arrived intact. createHash returns a stream you can pipe into, so even a file of many gigabytes is hashed with constant memory:

checksum.jsNode.js only
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import { appendFile, rm, writeFile } from "node:fs/promises";
import { pipeline } from "node:stream/promises";

await writeFile("invoices.csv", "id,amount\nINV-1042,45000\nINV-1043,12500\n");

async function sha256File(path) {
  const hasher = createHash("sha256");
  await pipeline(createReadStream(path), hasher);
  return hasher.digest("hex");
}

const published = "c419272338b2c9a1c9659a8c89cac88ad5e2b94f391c3f3f4060e8c854083a43";
console.log("intact file matches:", (await sha256File("invoices.csv")) === published);

await appendFile("invoices.csv", "INV-1044,1\n");
console.log("changed file matches:", (await sha256File("invoices.csv")) === published);
await rm("invoices.csv");
Output of node checksum.js
intact file matches: true
changed file matches: false

One extra line was enough to change the digest completely. A checksum protects against accidents such as a broken download. It does not protect against an attacker who can change both the file and the published checksum. For that you need a key, which brings us to HMAC.

HMAC: signing a webhook

A webhook is an HTTP request another service sends to your server when something happens, such as a payment succeeding. Payment providers (Paystack, Flutterwave, Stripe and others) solve the "anyone can send it" problem the same way. When you set up the webhook, the provider gives you a signing secret. For every notification it computes an HMAC of the request body with that secret and puts it in a header. Your server computes the same HMAC over the body it received. If they match, the sender knew the secret, and the body was not changed on the way.

HMAC is a standard (RFC 2104), so its results can be checked against published test values. Here is the standard one for HMAC-SHA256:

hmac-standard.jsNode.js only
import { createHmac } from "node:crypto";

const mac = createHmac("sha256", "key").update("The quick brown fox jumps over the lazy dog").digest("hex");
console.log(mac);
console.log(mac === "f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8");
Output of node hmac-standard.js
f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8
true

NOT sha256(secret + message)

Why not just hash the secret and the message together? Because SHA-256 has a weakness called length extension: from sha256(secret + message) an attacker can compute a valid digest for message + extra without knowing the secret. HMAC was designed to prevent exactly that. Use createHmac, never a home-made combination.

REASON IT OUT

Before you trust a payment notification

Your endpoint receives POST /webhooks/payments with a JSON body and an x-signature header. If it is genuine, your code marks the order paid and ships goods worth real money. Think through what can go wrong before you write the check:

  • Who can send a request to this URL?
  • Which exact bytes did the provider sign: the JSON object, or the text of the body?
  • The signature is a secret-dependent string. How should you compare it with yours?
  • Can the same genuine request arrive twice? What if an attacker records one and sends it again next week?
Show the reasoning

Anyone can send a request; the URL is not a secret. Only the HMAC proves origin.

The provider signed the raw bytes of the body. If you parse the JSON and turn it back into text, spacing and key order can change, and the HMAC no longer matches, even for a genuine request. Verify first, on the raw body, then parse.

Compare with timingSafeEqual, not ===, for reasons explained in the next section.

Yes, it can arrive twice: providers retry when your server was slow or returned an error. And a recorded request replayed later still has a valid signature. So remember which event ids you have processed and ignore repeats (this is called idempotency), and reject signatures whose timestamp is too old, when the provider signs a timestamp.

The next example shows the raw-body trap. The same data, parsed and turned back into JSON with different spacing, no longer matches:

raw-body.jsNode.js only
import { createHmac, randomBytes } from "node:crypto";

const secret = randomBytes(32);
const sign = (body) => createHmac("sha512", secret).update(body).digest("hex");

const rawBody = '{"event":"charge.success","data":{"reference":"ORD-1042","amount":4500000}}';
const header = sign(rawBody);

console.log("raw body verifies:       ", sign(rawBody) === header);
const reserialized = JSON.stringify(JSON.parse(rawBody), null, 2);
console.log("re-serialized verifies:  ", sign(reserialized) === header);

const tampered = rawBody.replace("4500000", "4500");
console.log("changed amount verifies: ", sign(tampered) === header);
Output of node raw-body.js
raw body verifies:        true
re-serialized verifies:   false
changed amount verifies:  false

This is why web frameworks give webhook routes access to the raw body. In a plain node:http server (Reading a request body) you already have it: collect the chunks into a Buffer and verify that before calling JSON.parse. The comparisons above use === only to show which inputs match; your real check needs the next section.

Timing attacks and timingSafeEqual

=== on two strings stops at the first character that differs. Comparing a wrong guess that starts with the right character takes a tiny bit longer than comparing one that starts wrong. Over a network, one comparison's difference is far smaller than the noise, but an attacker can send millions of requests and average them. Here the time is replaced by a count of the steps taken, which is what the time is made of:

leaky-compare.js
function leakyEqual(expected, guess) {
  let steps = 0;
  for (let i = 0; i < expected.length; i++) {
    steps++;
    if (expected[i] !== guess[i]) return { equal: false, steps };
  }
  return { equal: guess.length === expected.length, steps };
}

const signature = "7f3a9c";
for (const guess of ["000000", "700000", "7f0000", "7f3000", "7f3a9c"]) {
  console.log(guess, leakyEqual(signature, guess));
}
Output of node leaky-compare.js and of the browser terminal
000000 { equal: false, steps: 1 }
700000 { equal: false, steps: 2 }
7f0000 { equal: false, steps: 3 }
7f3000 { equal: false, steps: 4 }
7f3a9c { equal: true, steps: 6 }

Each correct character makes the comparison one step longer. That lets an attacker find the signature one character at a time: 16 tries per hex character instead of 1664 for the whole thing. This is a timing attack.

timingSafeEqual(a, b) compares two buffers in constant time: it always looks at every byte, so the time says nothing about where they differ. It requires both buffers to have the same length and throws otherwise, so check the length first (the length of a signature is not a secret):

safe-compare.jsNode.js only
import { timingSafeEqual } from "node:crypto";

function safeEqualHex(expectedHex, receivedHex) {
  const expected = Buffer.from(expectedHex, "hex");
  const received = Buffer.from(receivedHex, "hex");
  if (expected.length !== received.length) return false;
  return timingSafeEqual(expected, received);
}

console.log(safeEqualHex("7f3a9c", "7f3a9c"));
console.log(safeEqualHex("7f3a9c", "7f3a9d"));
console.log(safeEqualHex("7f3a9c", "7f3a"));

try {
  timingSafeEqual(Buffer.from("7f3a9c"), Buffer.from("7f3a"));
} catch (error) {
  console.log(error.code);
}
Output of node safe-compare.js
true
false
false
ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH

Use a constant-time comparison for anything secret-dependent that an attacker can make you compare over and over: signatures, API keys, reset-token hashes. Note that Buffer.from(text, "hex") silently stops at the first character that is not hex, so a header like "zz" becomes an empty buffer. The length check turns that into a clean false.

Passwords: slow on purpose

You cannot store a password encrypted, because whoever has the key (your server, and anyone who breaks into it) could decrypt all of them. You store a hash and, at login, hash the attempt and compare. But not with SHA-256. Look at what an attacker does with a leaked table:

fast-hash.jsNode.js only
import { hash } from "node:crypto";

const leaked = [
  { email: "ada@example.com", hash: hash("sha256", "password123") },
  { email: "bola@example.com", hash: hash("sha256", "iloveyou") },
  { email: "chidi@example.com", hash: hash("sha256", "password123") },
];

const commonPasswords = ["123456", "password", "iloveyou", "qwerty", "password123"];
const lookup = new Map(commonPasswords.map((pw) => [hash("sha256", pw), pw]));

for (const user of leaked) {
  console.log(user.email, "->", lookup.get(user.hash) ?? "not cracked");
}

let tries = 0;
const start = performance.now();
while (performance.now() - start < 200) hash("sha256", "guess" + tries++);
console.log("more than 100,000 guesses per second on one core:", tries * 5 > 100_000);
Output of node fast-hash.js
ada@example.com -> password123
bola@example.com -> iloveyou
chidi@example.com -> password123
more than 100,000 guesses per second on one core: true

Two weaknesses show up:

  • No salt. Ada and Chidi have the same hash, so cracking one cracks both, and a precomputed table of common passwords (a rainbow table) cracks everyone at once.
  • Too fast. One laptop core does hundreds of thousands of SHA-256 hashes per second; a graphics card does billions. Every common password falls in seconds.

A password hashing function fixes both. It takes a random salt per user, stored next to the hash, so equal passwords get different hashes and precomputed tables are useless. And it is deliberately slow and memory-hungry, tuned so one login takes tens of milliseconds for you but makes billions of guesses unaffordable. Node.js has scrypt built in.

Hashing and verifying with scrypt

scrypt's cost is set by three numbers: N (CPU and memory cost, a power of 2), r (block size) and p (parallelism). Memory use is about 128 × N × r bytes. The security guidance from OWASP (a well-known web security organisation) recommends at least N = 217, r = 8, p = 1, which needs 128 MiB. That is above Node.js's default memory limit for scrypt, so you must raise maxmem:

scrypt-maxmem.jsNode.js only
import { scryptSync } from "node:crypto";

try {
  scryptSync("password123", "salt", 64, { N: 2 ** 17, r: 8, p: 1 });
} catch (error) {
  console.log(error.code);
}

const ok = scryptSync("password123", "salt", 64, { N: 2 ** 17, r: 8, p: 1, maxmem: 256 * 1024 * 1024 });
console.log("derived", ok.length, "bytes");
Output of node scrypt-maxmem.js
ERR_CRYPTO_INVALID_SCRYPT_PARAMS
derived 64 bytes

Now a pair of functions you can actually use. The stored value is self-describing: it records the algorithm and the parameters next to the salt and hash, so you can raise the cost later and still verify old hashes.

passwords.jsNode.js only
import { randomBytes, scrypt, timingSafeEqual } from "node:crypto";
import { promisify } from "node:util";

const scryptAsync = promisify(scrypt);
export const PARAMS = { N: 2 ** 17, r: 8, p: 1 };
const KEY_LENGTH = 32;

function options({ N, r, p }) {
  return { N, r, p, maxmem: 256 * N * r };
}

export async function hashPassword(password) {
  const salt = randomBytes(16);
  const key = await scryptAsync(password.normalize("NFKC"), salt, KEY_LENGTH, options(PARAMS));
  const { N, r, p } = PARAMS;
  return `scrypt$${N}$${r}$${p}$${salt.toString("base64url")}$${key.toString("base64url")}`;
}

export async function verifyPassword(password, stored) {
  const [name, N, r, p, salt, key] = stored.split("$");
  if (name !== "scrypt") throw new Error(`unknown password hash format: ${name}`);
  const expected = Buffer.from(key, "base64url");
  const params = { N: Number(N), r: Number(r), p: Number(p) };
  const actual = await scryptAsync(password.normalize("NFKC"), Buffer.from(salt, "base64url"), expected.length, options(params));
  return timingSafeEqual(actual, expected);
}
login.jsNode.js only
import { hashPassword, verifyPassword } from "./passwords.js";

const ada = await hashPassword("correct horse battery staple");
const chidi = await hashPassword("correct horse battery staple");

console.log(ada.split("$").slice(0, 4).join("$"), "…");
console.log("same password, same stored value:", ada === chidi);
console.log("right password:", await verifyPassword("correct horse battery staple", ada));
console.log("wrong password:", await verifyPassword("correct horse battery stapler", ada));
Output of node login.js
scrypt$131072$8$1 …
same password, same stored value: false
right password: true
wrong password: false
  • The same password gives different stored values, because each has its own salt. The salt is not secret; its job is only to be unique.
  • verifyPassword reads the parameters from the stored value, not from PARAMS. When you raise the cost next year, old hashes still verify, and you can re-hash a user's password with the new cost right after a successful login, the only time you have the plain password.
  • Use the async scrypt, not scryptSync, in a server. The async version runs on Node.js's internal thread pool, the way file reads do, so the event loop keeps serving other requests during the hundreds of milliseconds it takes. scryptSync would block the whole server for every login, the problem you solved with workers in the previous lesson.

Normalizing the password

Why normalize("NFKC")? In Unicode, "é" can be stored as one character or as "e" plus a combining accent. They look identical, but they are different bytes, so they hash differently. A user who registered on a phone and logs in on a laptop could be locked out:

normalize.jsNode.js only
import { hash } from "node:crypto";

const phone = "café-2026";
const laptop = "café-2026";

console.log(phone === laptop, phone.length, laptop.length);
console.log("same hash:", hash("sha256", phone) === hash("sha256", laptop));
console.log("same after normalize:", hash("sha256", phone.normalize("NFKC")) === hash("sha256", laptop.normalize("NFKC")));
Output of node normalize.js
false 9 10
same hash: false
same after normalize: true

NOTE

OWASP's first choice today is Argon2id, a newer password hash that won the Password Hashing Competition. Node.js 24.7 and later include it as crypto.argon2(); before that it needed a package. bcrypt is older but still acceptable. scrypt with the parameters above is a sound, built-in choice. Whatever you pick, the rules are the same: a unique salt per password, a cost tuned so a hash takes around a hundred milliseconds on your server, and a stored format that records the parameters.

A few rules around the hash matter as much as the hash itself. Answer a failed login with the same message whether the e-mail exists or not ("invalid e-mail or password"). Limit login attempts per account and per IP address. And put a sensible maximum length on passwords (say 1,000 characters), so nobody can make your server hash a 10 MB password. Authentication security covers these in depth, and ZudoJS authentication packages them.

Encryption with AES-256-GCM

Bank account numbers for seller payouts must be read back, so a hash is no use. You need encryption. The standard choice is AES-256-GCM:

  • AES is the cipher, the most widely used and studied in the world. 256 is the key size in bits: a 32-byte key.
  • GCM is the mode. It encrypts and also produces an authentication tag, a 16-byte MAC over the ciphertext. When you decrypt, a changed ciphertext or tag is detected and rejected. Encryption that includes this check is called authenticated encryption, and it is the only kind you should use.
  • Every encryption needs a fresh 12-byte nonce (the IV). It is not secret and is stored with the ciphertext, but it must never repeat with the same key.
vault.jsNode.js only
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";

export function loadKey(base64) {
  const key = Buffer.from(base64 ?? "", "base64");
  if (key.length !== 32) throw new Error(`encryption key must be 32 bytes, got ${key.length}`);
  return key;
}

export function encrypt(key, plaintext, context) {
  const iv = randomBytes(12);
  const cipher = createCipheriv("aes-256-gcm", key, iv);
  cipher.setAAD(Buffer.from(context));
  const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
  const tag = cipher.getAuthTag();
  return ["v1", iv, tag, ciphertext].map((part) => (typeof part === "string" ? part : part.toString("base64url"))).join(".");
}

export function decrypt(key, sealed, context) {
  const [version, iv, tag, ciphertext] = sealed.split(".");
  if (version !== "v1") throw new Error(`unknown format ${version}`);
  const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(iv, "base64url"));
  decipher.setAAD(Buffer.from(context));
  decipher.setAuthTag(Buffer.from(tag, "base64url"));
  return Buffer.concat([decipher.update(Buffer.from(ciphertext, "base64url")), decipher.final()]).toString("utf8");
}

The context is AAD (additional authenticated data): text that is not encrypted, but is covered by the tag. Here it is the seller's id, which binds the ciphertext to its owner. Now use it, including the ways it should fail:

payouts.jsNode.js only
import { randomBytes } from "node:crypto";
import { decrypt, encrypt, loadKey } from "./vault.js";

const key = loadKey(randomBytes(32).toString("base64"));

const sealed = encrypt(key, "0123456789 GTBank", "seller:17");
console.log("parts:", sealed.split(".").length, "contains the number:", sealed.includes("0123456789"));
console.log("again, different:", encrypt(key, "0123456789 GTBank", "seller:17") !== sealed);
console.log("decrypted:", decrypt(key, sealed, "seller:17"));

const bytes = Buffer.from(sealed.split(".")[3], "base64url");
bytes[0] ^= 1;
const tampered = sealed.split(".").slice(0, 3).concat(bytes.toString("base64url")).join(".");

const attempts = [
  ["one flipped bit", () => decrypt(key, tampered, "seller:17")],
  ["copied to another seller", () => decrypt(key, sealed, "seller:18")],
  ["wrong key", () => decrypt(loadKey(randomBytes(32).toString("base64")), sealed, "seller:17")],
  ["key from a bad env var", () => loadKey("c2hvcnQ=")],
];
for (const [label, attempt] of attempts) {
  try {
    attempt();
    console.log(label, "-> decrypted?!");
  } catch (error) {
    console.log(label, "->", error.message);
  }
}
Output of node payouts.js
parts: 4 contains the number: false
again, different: true
decrypted: 0123456789 GTBank
one flipped bit -> Unsupported state or unable to authenticate data
copied to another seller -> Unsupported state or unable to authenticate data
wrong key -> Unsupported state or unable to authenticate data
key from a bad env var -> encryption key must be 32 bytes, got 5
  • The same plaintext encrypted twice gives different results, thanks to the random nonce. An attacker cannot even tell that two sellers use the same bank account.
  • One flipped bit, a ciphertext copied into another seller's row, and the wrong key all fail in decipher.final() with the same message. GCM does not tell you why; it only guarantees you never get silently wrong data.
  • In production, the key comes from an environment variable or a secret manager, as base64: loadKey(process.env.PAYOUT_KEY). loadKey refuses to start with a missing or short key, the same "fail at startup" rule as in What Node.js is. Make a key with node -p "crypto.randomBytes(32).toString('base64')".
  • The v1. prefix is a version. When you rotate keys, a v2 can mean "new key", and old values still decrypt with the old key until they are re-encrypted.

Never reuse a nonce

Why does the nonce matter so much? GCM encrypts by combining the plaintext with a stream of pseudo-random bytes made from the key and the nonce, using XOR (^, from Operators). Same key and same nonce means the same stream. Then XOR-ing two ciphertexts cancels the stream out, and whoever knows one message learns the other, without the key:

nonce-reuse.jsNode.js only
import { createCipheriv, randomBytes } from "node:crypto";

const key = randomBytes(32);
const fixedIv = Buffer.alloc(12);

function badEncrypt(text) {
  const cipher = createCipheriv("aes-256-gcm", key, fixedIv);
  return Buffer.concat([cipher.update(text, "utf8"), cipher.final()]);
}

const knownToAttacker = "PAY NGN 50000 TO ACCOUNT 0123456789";
const secret = "PAY NGN 90000 TO ACCOUNT 9876543210";
const c1 = badEncrypt(knownToAttacker);
const c2 = badEncrypt(secret);

const recovered = Buffer.alloc(c2.length);
for (let i = 0; i < c2.length; i++) recovered[i] = c1[i] ^ c2[i] ^ knownToAttacker.charCodeAt(i);
console.log(recovered.toString("utf8"));
Output of node nonce-reuse.js
PAY NGN 90000 TO ACCOUNT 9876543210

Worse, a repeated nonce also lets an attacker forge authentication tags. A random 12-byte nonce per message is safe for billions of messages with one key. A counter, a timestamp or a fixed value is not.

Build: an account security toolkit

Now put the pieces behind one small module, the way a real service would. It handles all four problems from the start of the lesson: passwords, webhooks, bank account numbers and reset tokens. The webhook signature follows the pattern many providers use: the header carries a timestamp and an HMAC of timestamp.body, so an old request cannot be replayed:

security.jsNode.js only
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";

export function signWebhook(secret, body, timestamp) {
  const mac = createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex");
  return `t=${timestamp},v1=${mac}`;
}

export function verifyWebhook(secret, rawBody, header, nowSeconds, seenIds, toleranceSeconds = 300) {
  const parts = Object.fromEntries((header ?? "").split(",").map((kv) => kv.split("=")));
  const timestamp = Number(parts.t);
  if (!Number.isInteger(timestamp) || !parts.v1) return "rejected: malformed header";
  if (Math.abs(nowSeconds - timestamp) > toleranceSeconds) return "rejected: too old";

  const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest();
  const received = Buffer.from(parts.v1, "hex");
  if (received.length !== expected.length || !timingSafeEqual(received, expected)) return "rejected: bad signature";

  const event = JSON.parse(rawBody);
  if (seenIds.has(event.id)) return `ignored: ${event.id} already processed`;
  seenIds.add(event.id);
  return `accepted: ${event.type} ${event.id}`;
}

export function newResetToken() {
  const token = randomBytes(32).toString("base64url");
  return { token, stored: createHash("sha256").update(token).digest("hex") };
}
demo.jsNode.js only
import { randomBytes } from "node:crypto";
import { signWebhook, verifyWebhook, newResetToken } from "./security.js";

const secret = randomBytes(32);
const seen = new Set();
const now = 1_790_000_000;
const body = JSON.stringify({ id: "evt_1042", type: "charge.success", amount: 4_500_000 });
const header = signWebhook(secret, body, now);

const cases = [
  ["genuine", body, header, now + 5],
  ["provider retry", body, header, now + 30],
  ["replayed next week", body, header, now + 7 * 24 * 3600],
  ["amount changed", body.replace("4500000", "4500"), header, now + 5],
  ["forged with a guessed secret", body, signWebhook("password", body, now), now + 5],
  ["no header", body, undefined, now + 5],
];
for (const [label, rawBody, sig, at] of cases) {
  console.log(label.padEnd(29), verifyWebhook(secret, rawBody, sig, at, seen));
}

const { token, stored } = newResetToken();
console.log("reset link token is 43 characters:", token.length === 43, "stored hash is 64 hex:", /^[0-9a-f]{64}$/.test(stored));
Output of node demo.js
genuine                       accepted: charge.success evt_1042
provider retry                ignored: evt_1042 already processed
replayed next week            rejected: too old
amount changed                rejected: bad signature
forged with a guessed secret  rejected: bad signature
no header                     rejected: malformed header
reset link token is 43 characters: true stored hash is 64 hex: true

Each rejection has its own line of defence:

  • The retry had a perfectly valid signature. Only the seenIds set stopped it from shipping the order twice. In a real service that set is a database table with a unique index on the event id.
  • The replay was also correctly signed, a week ago. The signed timestamp made it detectable. The tolerance (5 minutes here) allows for small clock differences between servers.
  • Changing the amount, or signing with a guessed secret, produced a different HMAC, compared in constant time.
  • The malformed-header check comes first, so garbage input never reaches JSON.parse. The order matters: verify, then parse, then act.

With passwords.js and vault.js from above, you now have all four tools. ZudoJS crypto offers the same building blocks as a package, and Applied cryptography in the security course goes further, into key management and signatures with public keys.

Failure cases, testing and production

The mistakes that cause real breaches

  • Passwords stored with SHA-256, MD5 or no salt. Use scrypt or Argon2id.
  • Secrets compared with ===. Use timingSafeEqual after a length check.
  • Tokens made with Math.random() or from the time. Use randomBytes.
  • Encryption without authentication (AES-CBC or AES-CTR on their own), or with a repeated nonce. Use AES-256-GCM with a random nonce per message.
  • Webhooks verified on re-serialized JSON, or not at all, or processed twice.
  • Keys in the source code or in Git. Load them from the environment, and never log them.
  • Home-made algorithms. You are not expected to invent cryptography; nobody is. Combine the standard pieces exactly as shown, or use a well-reviewed library.

Testing cryptographic code

You cannot eyeball whether encryption "looks random", so test behaviour instead:

  • Known answers. Standards publish test vectors, like the HMAC one above. Your HMAC wrapper must reproduce them.
  • Round trips. decrypt(encrypt(x)) === x for many inputs, including empty strings and emoji.
  • Every rejection. For each check you wrote (tampered data, wrong key, wrong context, old timestamp, repeated event, wrong length), a test that it fails. The payouts.js and demo.js examples are exactly that.
  • Uniqueness. Two encryptions of the same value differ; two hashes of the same password differ.
  • Speed of password hashing in tests. The real cost makes tests slow. Pass lower parameters in tests only, and assert in a separate test that production uses the real ones.

Production concerns

  • Key management. Keep keys in a secret manager or the platform's environment settings, with access limited to the services that need them. Plan rotation from day one: a version prefix and the ability to decrypt with the previous key.
  • Re-hash passwords when you raise the cost, at the next successful login.
  • Capacity. Each login costs about 100 ms of CPU and 128 MiB of memory with the parameters above. Node.js's thread pool runs 4 of these at a time by default (the UV_THREADPOOL_SIZE environment variable changes it), so rate-limit login attempts and measure how many your server can handle.
  • Logging. Never log passwords, tokens, keys, signatures or decrypted data. Log the event id and the result instead.
  • The browser. Browsers have their own crypto: crypto.getRandomValues(), crypto.randomUUID() and the Web Crypto API in crypto.subtle, whose functions return promises. Node.js has the same API as globalThis.crypto, which helps when code must run in both. The test for this lesson uses it.

Practice

TRY IT YOURSELF

Re-hash when the cost goes up

Add a needsRehash(stored) function to the password module that returns true when a stored scrypt value used different parameters than the current PARAMS. Show it on a hash made with N = 2 ** 15 and one made with the current parameters.

Show a solution
rehash.jsNode.js only
import { PARAMS, hashPassword } from "./passwords.js";

function needsRehash(stored) {
  const [, N, r, p] = stored.split("$").map(Number);
  return N !== PARAMS.N || r !== PARAMS.r || p !== PARAMS.p;
}

const old = "scrypt$32768$8$1$c2FsdHNhbHRzYWx0c2FsdA$aGFzaGhhc2hoYXNoaGFzaGhhc2hoYXNoaGFzaGhhc2g";
console.log("old cost:", needsRehash(old));
console.log("current cost:", needsRehash(await hashPassword("correct horse battery staple")));
Output of node rehash.js
old cost: true
current cost: false

At login: if (await verifyPassword(pw, stored) && needsRehash(stored)) save(await hashPassword(pw)). Over time every active user moves to the new cost, without anyone having to reset a password.

TRY IT YOURSELF

Verify a Paystack-style webhook

Some providers sign the raw body alone with HMAC-SHA512 and send the hex digest in a header, with no timestamp. Write verifySha512(secret, rawBody, headerHex) with a constant-time comparison, test it on a genuine body, a changed body and an empty header, and explain what protects you from replays in this scheme.

Show a solution
sha512-webhook.jsNode.js only
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";

function verifySha512(secret, rawBody, headerHex) {
  const expected = createHmac("sha512", secret).update(rawBody).digest();
  const received = Buffer.from(headerHex ?? "", "hex");
  return received.length === expected.length && timingSafeEqual(received, expected);
}

const secret = randomBytes(32);
const body = '{"event":"charge.success","data":{"reference":"ORD-1042","amount":4500000}}';
const header = createHmac("sha512", secret).update(body).digest("hex");

console.log("genuine:", verifySha512(secret, body, header));
console.log("changed:", verifySha512(secret, body.replace("ORD-1042", "ORD-1043"), header));
console.log("empty header:", verifySha512(secret, body, ""));
Output of node sha512-webhook.js
genuine: true
changed: false
empty header: false

Without a signed timestamp, a recorded request stays valid forever. The only protection is idempotency: store each processed reference (or event id) with a unique constraint, and treat a repeat as "already done". Some providers also publish the IP addresses their webhooks come from, which is a useful extra filter.

TRY IT YOURSELF

Rotate an encryption key

Write decryptAny(keys, sealed, context) that takes a map of key versions ({ v1: oldKey, v2: newKey }) and picks the key by the prefix, and reencrypt that moves a value from v1 to v2. Use encrypt and decrypt from vault.js, replacing the v1 label.

Show a solution
rotate.jsNode.js only
import { randomBytes } from "node:crypto";
import { decrypt, encrypt } from "./vault.js";

const keys = { v1: randomBytes(32), v2: randomBytes(32) };
const CURRENT = "v2";

function seal(plaintext, context) {
  return encrypt(keys[CURRENT], plaintext, context).replace(/^v1\./, `${CURRENT}.`);
}

function decryptAny(sealed, context) {
  const [version] = sealed.split(".");
  if (!keys[version]) throw new Error(`no key for ${version}`);
  return decrypt(keys[version], sealed.replace(/^v\d+\./, "v1."), context);
}

const old = encrypt(keys.v1, "0123456789 GTBank", "seller:17");
const moved = seal(decryptAny(old, "seller:17"), "seller:17");
console.log(old.split(".")[0], "->", moved.split(".")[0]);
console.log(decryptAny(moved, "seller:17"));
Output of node rotate.js
v1 -> v2
0123456789 GTBank

Rotation is: add the new key, write everything new with it, re-encrypt old values in a background job, and only remove the old key when no v1 values remain. In a real module the version would be a parameter of encrypt rather than a string replacement.

Recap

  • Unguessable values come from randomBytes (32 bytes for tokens), randomInt (codes, without modulo bias) and randomUUID (ids). Store hashes of tokens, not the tokens.
  • SHA-256 fingerprints data and detects accidental changes. MD5 and SHA-1 are broken.
  • HMAC proves a message came from someone with the key. Verify webhooks on the raw body, compare with timingSafeEqual after a length check, reject old timestamps and ignore repeated event ids.
  • Passwords need a slow, salted, memory-hard hash: scrypt with N = 217, r = 8, p = 1 (raise maxmem), or Argon2id. Use the async version and a self-describing stored format.
  • Data you must read back needs AES-256-GCM: a 32-byte key from the environment, a fresh 12-byte nonce every time, the tag checked on decrypt, and AAD to bind the data to its owner. A repeated nonce breaks it.
  • Encoding (hex, base64) is not encryption, and hashing is not encryption.

Next, npm and packages brings other people's code into your project, and the lesson after it looks at how the npm ecosystem stays trustworthy, which uses the hashes and signatures you have just learned.

Test yourself

Five questions, picked at random from this lesson's question bank. Some ask you to choose an answer, some to predict what code prints, and some to write code and run it in the terminal. Get 4 of 5 right to pass. If you don't, read the explanations and try again: you get 5 different questions.