Everyday Cryptography with node:crypto: randomUUID, Hashes, HMAC, Passwords with scrypt, timingSafeEqual and Web Crypto
In Node.js you verify a webhook signature with createHmac over the raw body and compare it with timingSafeEqual, store passwords with scrypt or the built-in Argon2id, and encrypt sensitive fields with AES-256-GCM and a fresh IV every time. With real output: a signature broken by JSON.stringify, SHA-256 at over two million per second versus 169 ms scrypt, 622 MB for 16 concurrent sign-ins, and a phone number recovered without the key from a fixed IV.
Version note: The examples in this post were run with Node.js 26.13.1 (LTS; node:crypto as shipped with its OpenSSL) and TypeScript 7.0.2.
Guess First
The payment provider of Bookstore, the online bookstore used as the shared example across this site's series, sends the API a webhook when a payment completes and signs the body with a shared secret: an HMAC. Our handler recomputes the signature from the req.body that express.json() parsed:
import { createHmac } from "node:crypto";
const secret = "test-webhook-secret-not-real";
// The payment provider signs exactly the bytes it sends (spaces included)
const rawBody = '{"id": "pay-evt-1001", "type": "payment.succeeded", "orderId": "1001", "amountMinor": 34990, "currency": "TRY"}';
const sent = createHmac("sha256", secret).update(rawBody).digest("hex");
// Our handler gets req.body from express.json() and signs it again
const parsed = JSON.parse(rawBody);
const ours = createHmac("sha256", secret).update(JSON.stringify(parsed)).digest("hex");
console.log(sent === ours ? "signature OK" : "signature mismatch");
Same body, same key. Do the signatures match?
signature mismatch
They do not. The provider signed the bytes it sent, and those bytes have a space after each colon. JSON.stringify wrote the same object without spaces, and a single byte of difference changes the HMAC completely. Key order, number formatting and Unicode escapes cause the same problem. The right way is to verify the signature over the raw bytes that came with the request. In this post we use node:crypto for the jobs a backend needs every day: random values, hashes and HMACs, a webhook signature, password storage and encrypting a single field. The concepts themselves are covered in posts 02 and 03 of the Cryptography for Developers series; here we look at their Node counterparts.
Random Values: randomUUID, randomBytes and randomInt
When you generate a token, a password reset code or an order number, you do not use Math.random(): V8's function is a fast number generator, but its output is predictable. The functions in node:crypto use the operating system's cryptographic random number generator:
import { randomBytes, randomInt, randomUUID, randomUUIDv7 } from "node:crypto";
// All of these come from the operating system's CSPRNG; Math.random() does not
console.log("randomUUID ", randomUUID());
console.log("randomUUIDv7 ", randomUUIDv7(), randomUUIDv7());
console.log("token ", randomBytes(32).toString("base64url"));
console.log("order code ", randomInt(100_000, 1_000_000));
randomUUID e26efb07-954d-4549-854e-cb11a0bab4c5
randomUUIDv7 01a1de25-2f15-7344-8943-393df98cc1be 01a1de25-2f15-7644-9723-182a624bfa27
token PAspyu04GR1QsTSuwuXARrAX1F607U9mFJvbuKoIAaM
order code 725815
randomUUID produces a version 4 UUID; in .NET that is Guid.NewGuid(). randomUUIDv7, new in 26.1, puts the time in milliseconds into its first 48 bits; the two values start with the same prefix, so used as a primary key they are inserted into the index in order (Guid.CreateVersion7() in .NET 9). According to the documentation the clock inside is not monotonic, so do not rely on the order being strict. randomBytes(32).toString("base64url") is a 256-bit token you can put in a URL; randomInt gives an evenly distributed number in a range, without modulo bias.
Hashes and HMACs
createHash("sha256") produces a fixed-length fingerprint of some data: to tell whether a file changed, or to build a cache key or an ETag. For one-off calls, crypto.hash("sha256", data), stable since 25.5, is shorter. A hash has no key; whoever changes the data can recompute the hash too. To say "someone who knows the key sent this message" you need an HMAC: createHmac("sha256", secret). The .NET counterparts are SHA256.HashData and HMACSHA256.HashData.
Bookstore: Verifying the Payment Webhook's Signature
The provider sends the signature in the x-payment-signature: t=<unix-time>,sig=<hex> header, computed over `${t}.${body}`. Including the time in the signature is how you stop an old request from being sent again as it is (a replay):
// Header sent by the payment provider: "t=<unix seconds>,sig=<hex HMAC-SHA256 of `${t}.${raw body}`>"
export const SIGNATURE_HEADER = "x-payment-signature";
const TOLERANCE_SECONDS = 300;
export type SignatureCheck = { ok: true } | { ok: false; reason: "missing" | "malformed" | "expired" | "mismatch" };
export function signPayload(rawBody: Buffer, secret: string, timestamp: number): string {
const sig = createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex");
return `t=${timestamp},sig=${sig}`;
}
// Verifies the signature over the exact bytes that arrived, never over re-serialised JSON
export function verifyWebhookSignature(rawBody: Buffer, header: string | undefined, secret: string, nowSeconds = Math.floor(Date.now() / 1000)): SignatureCheck {
if (!header) {
return { ok: false, reason: "missing" };
}
const match = /^t=(\d{1,12}),sig=([0-9a-f]{64})$/.exec(header);
if (!match) {
return { ok: false, reason: "malformed" };
}
const timestamp = Number(match[1]);
// An old but valid signature is a replayed request: accept only a few minutes of clock difference
if (Math.abs(nowSeconds - timestamp) > TOLERANCE_SECONDS) {
return { ok: false, reason: "expired" };
}
const expected = Buffer.from(signPayload(rawBody, secret, timestamp).split("sig=")[1], "hex");
const received = Buffer.from(match[2], "hex");
// Constant time: the comparison takes as long for the first wrong byte as for the last one
return timingSafeEqual(expected, received) ? { ok: true } : { ok: false, reason: "mismatch" };
}
We compare with timingSafeEqual, not ===. === stops at the first differing character; an attacker measuring response times could try to guess the right signature byte by byte. timingSafeEqual always compares every byte. Its one condition is that both buffers have the same length:
ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH: Input buffers must have the same byte length
That is why the code first pins the header to 64 hex characters with a regex; the length is known in advance, and the comparison never throws. Unlike the carrier endpoint of post 18, the route itself asks for no bearer token; the signature is the authentication:
// POST /api/payments/webhook: no bearer token; the HMAC signature is the authentication
export function paymentWebhookRouter(secret: string, handle: PaymentEventHandler): Router {
// express.raw keeps the body as a Buffer: the signature covers the bytes, not the parsed object
return Router().post("/", express.raw({ type: "application/json", limit: "64kb" }), async (req, res) => {
const body: Buffer = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
const check = verifyWebhookSignature(body, req.get(SIGNATURE_HEADER), secret);
if (!check.ok) {
logger.warn({ reason: check.reason }, "webhook signature rejected");
throw new HttpError(401, "invalid webhook signature");
}
let json: unknown;
try {
json = JSON.parse(body.toString("utf8"));
} catch {
throw new HttpError(400, "webhook body is not valid JSON");
}
const parsed = paymentEventSchema.safeParse(json);
if (!parsed.success) {
throw new HttpError(400, "unexpected webhook payload");
}
const isNew = await handle(parsed.data);
// 200 for duplicates too: the provider must stop retrying an event we already have
res.status(200).json({ received: true, duplicate: !isNew });
});
}
This router is registered in app.ts before express.json(). The order matters: had express.json() run first, it would have read and consumed the body stream, and the raw bytes would be gone. If the same event arrives twice we return 200 but do not process it again; providers resend webhooks to guarantee delivery. The event ids live in memory for now; in production they belong in a table with a unique key. The tests:
✔ accepts a signed event once and reports the retry as a duplicate (12.441125ms)
✔ rejects a changed body, a wrong secret and an old timestamp (3.429583ms)
✔ round-trips and never produces the same ciphertext twice (0.813375ms)
✔ fails for another row's associated data (0.242167ms)
✔ rejects keys that are not 32 bytes (0.084291ms)
✔ verifies the right password and rejects a wrong one (498.916042ms)
✔ uses a new salt every time (316.726166ms)
✔ treats composed and decomposed Turkish letters as the same password (320.82675ms)
✔ asks for a re-hash of hashes made with weaker parameters (0.180167ms)
ℹ tests 9
ℹ pass 9
ℹ fail 0
Passwords: scrypt, Argon2 and Why Not SHA-256
Storing passwords as plain text is wrong, but storing them with a fast hash is almost as wrong. If the database is stolen, the attacker computes the hash of every word in a dictionary and compares. We measured how fast they could try (one CPU core, on this machine):
md5 0.0004 ms/hash 2,326,128 hashes/s
sha1 0.0004 ms/hash 2,512,878 hashes/s
sha256 0.0004 ms/hash 2,313,792 hashes/s
scrypt N=2^14 r=8 p=1 (default) 20.6062 ms/hash 49 hashes/s
scrypt N=2^17 r=8 p=1 (OWASP) 168.7707 ms/hash 6 hashes/s
argon2id m=19 MiB t=2 p=1 (OWASP) 11.1777 ms/hash 89 hashes/s
md5, sha1 and sha256 exceed two million per second on a single core; on a GPU that number multiplies. Password hashes are slow and memory-hungry on purpose. OWASP's Password Storage Cheat Sheet recommends Argon2id first (at least 19 MiB of memory, 2 passes), and scrypt (N=2^17, r=8, p=1) when Argon2id is not available. Two notes: since Node 24.7, crypto.argon2() is built in, so a native npm package is no longer needed. Node's default scrypt settings (N=2^14, r=8, p=1), on the other hand, fall short of the p=5 that OWASP asks for at that N. ASP.NET Core Identity's PasswordHasher uses PBKDF2, which is also an accepted choice when the iteration count is kept high.
In Bookstore we use scrypt with the OWASP settings and store the settings next to the hash:
// OWASP Password Storage Cheat Sheet: scrypt with N=2^17 (128 MiB), r=8, p=1
const CURRENT = { logN: 17, r: 8, p: 1 };
const KEY_LENGTH = 32;
function scryptAsync(password: string, salt: Buffer, options: ScryptOptions): Promise<Buffer> {
return new Promise((resolve, reject) =>
scrypt(password, salt, KEY_LENGTH, options, (err, key) => (err ? reject(err) : resolve(key))),
);
}
function options(logN: number, r: number, p: number): ScryptOptions {
const N = 2 ** logN;
// The default maxmem is 32 MiB; scrypt needs about 128 * N * r bytes
return { N, r, p, maxmem: 256 * N * r };
}
// PHC-style string: the parameters are stored with the hash, so they can be raised later
export async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16);
const { logN, r, p } = CURRENT;
const key = await scryptAsync(password.normalize("NFC"), salt, options(logN, r, p));
return `$scrypt$ln=${logN},r=${r},p=${p}$${salt.toString("base64url")}$${key.toString("base64url")}`;
}
const STORED = /^\$scrypt\$ln=(\d+),r=(\d+),p=(\d+)\$([\w-]+)\$([\w-]+)$/;
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
const match = STORED.exec(stored);
if (!match) {
return false;
}
const [, logN, r, p, salt, hash] = match;
const expected = Buffer.from(hash, "base64url");
const actual = await scryptAsync(password.normalize("NFC"), Buffer.from(salt, "base64url"), options(Number(logN), Number(r), Number(p)));
return expected.length === actual.length && timingSafeEqual(expected, actual);
}
// True when the stored hash was made with weaker parameters than today's: re-hash it at the next sign-in
export function needsRehash(stored: string): boolean {
const match = STORED.exec(stored);
return !match || Number(match[1]) < CURRENT.logN || Number(match[2]) < CURRENT.r || Number(match[3]) < CURRENT.p;
}
The stored value resembles the PHC format: $scrypt$ln=17,r=8,p=1$<salt>$<hash>. Every password gets its own random salt; two customers with the same password have different hashes. Because the settings are inside the string, raising N tomorrow keeps old hashes verifiable; when needsRehash returns true, you hash the password again with the new settings at the user's next sign-in. normalize("NFC") makes two different Unicode spellings of the same-looking Turkish letter count as the same password; a test checks this.
These settings have a cost. When 16 users sign in at the same moment:
// 16 sign-ins at the same moment: what happens to time, memory and the event loop?
const stored = await hashPassword("correct horse battery staple");
const delay = monitorEventLoopDelay({ resolution: 10 });
let peak = 0;
const sampler = setInterval(() => (peak = Math.max(peak, process.memoryUsage.rss())), 10);
delay.enable();
const started = performance.now();
const results = await Promise.all(Array.from({ length: 16 }, () => verifyPassword("correct horse battery staple", stored)));
const elapsed = performance.now() - started;
delay.disable();
clearInterval(sampler);
console.log(`16 verifications: all ${results.every(Boolean)}, ${Math.round(elapsed)} ms, peak RSS ${Math.round(peak / 1e6)} MB, max event loop delay ${Math.round(delay.max / 1e6)} ms`);
16 verifications: all true, 739 ms, peak RSS 622 MB, max event loop delay 12 ms
The event loop barely waited: the async version of scrypt runs on the libuv thread pool we saw in post 06. But every hash needs 128 MiB, and with four threads running at once RSS rose to 622 MB. With UV_THREADPOOL_SIZE=8 the time dropped to 543 ms and RSS rose to 1159 MB. Take this number into account when you set a container's memory limit; and never use scryptSync, because every call stops the event loop for 170 ms.
Field Encryption with AES-256-GCM
For Institutional Orders we store the customer's phone number for delivery. Even if a database backup falls into the wrong hands, we do not want this field to be readable. For that we use AES in GCM mode, which checks integrity as it encrypts:
// associatedData binds the ciphertext to its row: a value copied to another customer fails to decrypt
export function encryptField(plaintext: string, key: Buffer, associatedData: string): string {
// A new random 96-bit IV for every value; never reuse an IV with the same key
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", key, iv);
cipher.setAAD(Buffer.from(associatedData, "utf8"));
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
return [VERSION, ...[iv, ciphertext, cipher.getAuthTag()].map((part) => part.toString("base64url"))].join(".");
}
export function decryptField(value: string, key: Buffer, associatedData: string): string {
const [version, iv, ciphertext, tag] = value.split(".");
if (version !== VERSION || !iv || !ciphertext || !tag) {
throw new Error("not an encrypted field value");
}
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(iv, "base64url"));
decipher.setAAD(Buffer.from(associatedData, "utf8"));
decipher.setAuthTag(Buffer.from(tag, "base64url"));
// final() throws if a single bit of the ciphertext, the tag or the associated data changed
return Buffer.concat([decipher.update(Buffer.from(ciphertext, "base64url")), decipher.final()]).toString("utf8");
}
stored: v1.mqSBB8NPcwl3E4jd.9WDgB_QYMjxkiZpkoosI5iE.BdJ-TBLRgZA9rzFq149Z3A
decrypted: +90 500 000 00 01
again: v1.EBeXUGZaYluxlOJk.vtQ-Hm9Bygc2wV1i2klGsHE.nfLdlhqJgioYfXUHxiNKyw
tampered ciphertext: Unsupported state or unable to authenticate data
copied to alan : Unsupported state or unable to authenticate data
The same number encrypted twice gave two different values: every encryption uses a new IV. When a single character of the ciphertext changed, final() threw; GCM's authentication tag catches it. associatedData is a value that is not encrypted but is covered by the tag; with the row's identity in it, Ada's encrypted number copied into Alan's row cannot be decrypted.
The most dangerous mistake with GCM is using the same IV twice with the same key. Let's try it with a fixed IV from a config file:
// The bug: one fixed IV for every value (for example a constant in the config file)
const key = randomBytes(32);
const fixedIv = Buffer.alloc(12, 7);
function encrypt(text: string): Buffer {
const cipher = createCipheriv("aes-256-gcm", key, fixedIv);
return Buffer.concat([cipher.update(text, "utf8"), cipher.final()]);
}
const adaPhone = encrypt("+90 500 000 00 01");
const alanPhone = encrypt("+90 500 999 12 34");
// GCM encrypts with a key stream (CTR mode): same key + same IV = same key stream.
// XOR of the two ciphertexts = XOR of the two plaintexts; knowing one value reveals the other
const knownAda = Buffer.from("+90 500 000 00 01", "utf8");
const recovered = Buffer.from(adaPhone.map((byte, i) => byte ^ alanPhone[i] ^ knownAda[i]));
console.log("recovered without the key:", recovered.toString("utf8"));
recovered without the key: +90 500 999 12 34
Alan's number came out without the key ever being used. GCM is built on CTR mode; the same key and IV produce the same key stream, and the XOR of the two ciphertexts equals the XOR of the two plaintexts. The rule is simple: generate the IV with randomBytes(12) for every encryption and store it next to the ciphertext; it is not secret. With random IVs there is also a limit on how many encryptions one key can do (about 2^32); post 03 of the Cryptography for Developers series explains that limit and key changes. The key itself is not in the code or the repo; it comes from configuration, preferably from a secret store, and parseKey rejects a key that is not 32 bytes at startup.
node:crypto or Web Crypto?
Node has two cryptography APIs. node:crypto is Node-specific, mostly synchronous and broad. globalThis.crypto.subtle is the browser's Web Crypto API: async and narrower, but the same code runs in browsers, Deno, Bun and edge runtimes. Let's compute the same HMAC with both:
const secret = "test-webhook-secret-not-real";
const payload = '1763190000.{"id":"pay-evt-1001","type":"payment.succeeded"}';
// node:crypto: synchronous, Node only
const viaNode = createHmac("sha256", secret).update(payload).digest("hex");
// Web Crypto (globalThis.crypto.subtle): async, the same API in browsers, Deno, Bun and edge runtimes
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(payload));
const viaWeb = Buffer.from(signature).toString("hex");
console.log(viaNode === viaWeb ? "same signature" : "different");
// verify() compares in constant time for you
console.log("verify:", await crypto.subtle.verify("HMAC", key, Buffer.from(viaNode, "hex"), encoder.encode(payload)));
same signature
verify: true
The result is the same. In server code node:crypto is more practical (Web Crypto has no scrypt or Argon2); if the code is shared with the browser or another runtime, use Web Crypto. Web Crypto's verify() does the constant-time comparison itself.
Production Notes
Secrets (the webhook secret, the field encryption key) come through the configuration of post 14, from an environment variable or a secret store; loadConfig refuses to start the API without PAYMENT_WEBHOOK_SECRET. They never go to the log; add these fields to the pino redaction of post 14.
Plan key rotation from the start. The v1 prefix on encrypted values and the parameters in the password hash exist for this: when a new key or new settings arrive, old values stay readable and new ones are written with the new settings.
Put the cost of password hashing into your capacity plan. Always apply the rate limit of post 15 to the sign-in endpoint; otherwise an attacker turns your CPU and memory against you.
Common Pitfalls
Generating tokens with Math.random(). It is predictable; use randomBytes or randomUUID.
Comparing signatures with ===. The timing difference leaks information; use timingSafeEqual and fix the lengths beforehand.
Verifying the signature over parsed JSON. Use the raw bytes; register the route before express.json().
Storing passwords with md5, sha1 or unsalted sha256. Use scrypt or Argon2id.
AES-GCM with a fixed IV. Repeating it once can expose two values at the same time.
A Small Experiment
★☆☆ Fix labs/20/guess.ts so that it computes the signature over rawBody, and compare the result with timingSafeEqual.
★★☆ Add Argon2id support to passwords.ts: new hashes are written as $argon2id$v=19$m=19456,t=2,p=1$..., old scrypt hashes still verify, and needsRehash returns true for them.
★★★ Add key rotation to fieldEncryption.ts: the value's prefix carries the key's identity (v1.k2.), decryption recognises old keys too, and new values are always written with the current key. Write a test that reads a value encrypted with an old key and writes it back.
In the next post we turn to CPU-heavy work: with worker_threads, transferables and Piscina we will process a large catalog file without blocking the event loop.