Node crypto, used right: KDFs, HMAC, signatures, and constant-time compares
Node's crypto is correct only if you pick the right primitive: scrypt for passwords, HMAC for authenticated integrity, randomBytes for secrets, timingSafeEqual to compare them. Fast hashes and === are the bugs.
A team stored passwords as sha256(password) — fast, “hashed”, looks fine in code review. Then a backup leaked. Because SHA-256 is built to be fast, an attacker’s GPU tried billions of guesses per second against every row at once, and the unsalted digests meant a single precomputed rainbow table cracked the common passwords instantly. The same codebase compared API tokens with ===, leaking timing that let an attacker recover a valid token byte by byte. Every one of these is crypto used with the wrong primitive — and the right one was one function call away.
Hashing vs HMAC: integrity, then authenticated integrity
A plain hash answers one question: did these bytes change? crypto.createHash('sha256') maps input to a fixed digest, so the same input always yields the same output and any tampering changes it. That is fine for a content checksum or a cache key — but it proves nothing about who produced it. Anyone can recompute the digest, so an attacker who alters a message can simply recompute its hash and you cannot tell.
HMAC closes that gap. It mixes a secret key into the hash, so only parties holding the key can produce or verify a valid tag. Use a plain hash for integrity against accidental corruption; use HMAC when you need authenticated integrity — proving a value came from someone who knows the key. Signed cookies, webhook signatures (Stripe, GitHub), and stateless tokens all rely on HMAC, not a bare hash.
const crypto = require('node:crypto');
// Plain hash — integrity only, anyone can recompute it
const digest = crypto.createHash('sha256').update(payload).digest('hex');
// HMAC — keyed, only key holders can produce/verify the tag
const key = process.env.WEBHOOK_SECRET;
const tag = crypto.createHmac('sha256', key).update(rawBody).digest('hex');
// later: compare `tag` to the header value with timingSafeEqual (see below)Passwords need a KDF, never a fast hash
The hook’s bug is the most common crypto mistake in web apps: hashing passwords with a fast algorithm. SHA-256, SHA-512, and MD5 are designed to be fast, which is exactly what you do not want for passwords — speed helps the attacker brute-force offline. A leaked database of unsalted SHA-256 digests falls to GPUs doing billions of guesses per second, and identical passwords produce identical digests, so rainbow tables and “crack once, match many” attacks work.
Password storage needs a key derivation function (KDF): deliberately slow, memory-hard, and per-password salted. Node ships scrypt and pbkdf2 natively; argon2 and bcrypt are available via libraries. The salt (from a CSPRNG) makes every hash unique so identical passwords differ and rainbow tables die; the cost factor makes each guess expensive. Always use the async form so you don’t block the event loop while the KDF deliberately burns CPU.
const crypto = require('node:crypto');
function hashPassword(password) {
return new Promise((resolve, reject) => {
const salt = crypto.randomBytes(16); // unique per password, from CSPRNG
// N=2**15 cost, 64-byte output; store salt alongside the hash.
// maxmem must be raised: scrypt needs 128*N*r bytes (here 32 MiB), and the
// default maxmem cap is exactly 32 MiB, so any real cost throws without it.
crypto.scrypt(password, salt, 64, { N: 2 ** 15, maxmem: 64 * 1024 * 1024 }, (err, hash) => {
if (err) return reject(err);
resolve(`${salt.toString('hex')}:${hash.toString('hex')}`);
});
});
}
function verifyPassword(password, stored) {
return new Promise((resolve, reject) => {
const [saltHex, hashHex] = stored.split(':');
const salt = Buffer.from(saltHex, 'hex');
const expected = Buffer.from(hashHex, 'hex');
crypto.scrypt(password, salt, expected.length, { N: 2 ** 15, maxmem: 64 * 1024 * 1024 }, (err, actual) => {
if (err) return reject(err);
resolve(crypto.timingSafeEqual(actual, expected)); // constant-time, see below
});
});
}
// usage — hash on signup, verify on login (also a smoke test that the cost params run)
const stored = await hashPassword('correct horse battery staple');
console.log('verify correct:', await verifyPassword('correct horse battery staple', stored)); // true
console.log('verify wrong: ', await verifyPassword('wrong', stored)); // false▸Why this works
Why is “fast” the enemy? A normal login checks one password per request — microseconds either way is invisible to the user. But an attacker with a leaked hash dump runs offline against your stored hashes with no rate limit and no network round-trip. If your hash is fast, they test billions per second; if it is a tuned scrypt with high N, they test thousands. The cost factor is a dial you turn up over the years as hardware improves — the asymmetry between one honest login and a billion attacker guesses is the entire point.
Asymmetric signatures: prove origin without sharing a secret
HMAC works when both sides share one secret. When they cannot — you want to publish something anyone can verify but only you can produce (a software release, a JWT signed by an auth server, a licence) — you need asymmetric signing. You sign with a private key and anyone verifies with the matching public key. The public key proves nothing about the holder’s ability to forge; only the private key can sign.
const crypto = require('node:crypto');
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519');
// Sign with the private key (Ed25519 takes null for the algorithm arg)
const signature = crypto.sign(null, Buffer.from(message), privateKey);
// Anyone with the public key verifies — returns true/false
const ok = crypto.verify(null, Buffer.from(message), publicKey, signature);For streaming or large inputs use the crypto.createSign('SHA256') / crypto.createVerify('SHA256') object API with RSA or ECDSA keys: .update(chunk) repeatedly, then .sign(privateKey) / .verify(publicKey, sig). The key choice — HMAC vs signature — is about whether verifiers can be trusted with the signing power.
You must let many third parties verify that a message came from your service, without any of them being able to forge messages. Pick the primitive.
Randomness and constant-time comparison: the two silent footguns
Two more mistakes are subtle because the code runs fine. When you reach for Math.random() to generate a token, nothing breaks — until it does. First, randomness: Math.random() is a fast PRNG with no cryptographic guarantees — its output is predictable and must never generate tokens, salts, session IDs, or keys. Use crypto.randomBytes(n) (a CSPRNG) for raw bytes and crypto.randomUUID() for IDs. Second, comparison: comparing secrets with ===, ==, or Buffer.compare early-exits on the first differing byte, so the time it takes leaks how many leading bytes matched — and an attacker can recover a token byte by byte by measuring response times. Use crypto.timingSafeEqual(a, b), which compares in constant time. It requires two Buffers of equal length (it throws otherwise), so hash both sides to a fixed length first, or length-check before calling.
const crypto = require('node:crypto');
// WRONG — leaks how many leading bytes matched via timing
if (userToken === storedToken) grantAccess();
// RIGHT — constant-time; both Buffers must be the same length
const a = Buffer.from(userToken);
const b = Buffer.from(storedToken);
if (a.length === b.length && crypto.timingSafeEqual(a, b)) grantAccess();| Job | Right primitive | Wrong choice & why it fails |
|---|---|---|
| Store a password | crypto.scrypt / pbkdf2 (async, salted, high cost) | sha256/md5 — fast + unsalted → GPU brute force, rainbow tables |
| Authenticated integrity (shared key) | crypto.createHmac | bare createHash — no key, attacker recomputes the digest |
| Verifiable origin, untrusted verifiers | crypto.sign/verify (Ed25519/RSA) | HMAC — every verifier can also forge |
| Token / salt / session ID | crypto.randomBytes / randomUUID | Math.random() — predictable, not a CSPRNG |
| Compare two secrets | crypto.timingSafeEqual (equal-length Buffers) | ===/== — early-exit leaks timing → byte-by-byte recovery |
A service stores passwords as crypto.createHash('sha256').update(password).digest('hex'). Why is this insecure?
Why replace `userToken === storedToken` with crypto.timingSafeEqual for comparing a secret?
Order the steps to verify a submitted password against a stored scrypt hash, securely:
- 1 Split the stored value into its salt and expected hash
- 2 Re-run scrypt on the submitted password with that salt and the same cost factor
- 3 Confirm the derived hash and expected hash are equal-length Buffers
- 4 Compare them with crypto.timingSafeEqual (constant-time)
- 5 Grant or deny access based on the boolean result
- 01Why is storing passwords with sha256 insecure, and what should you use instead?
- 02Why does comparing a secret with === leak information, and how does timingSafeEqual fix it?
Node’s crypto module is only as safe as your choice of primitive. A plain hash (createHash) gives integrity but no authenticity — anyone can recompute it — so reach for HMAC (createHmac) when you need to prove a value came from a key holder, as with webhook signatures and signed cookies. Passwords are a category of their own: never a fast hash like SHA-256 or MD5, which fall to offline GPU brute force and rainbow tables, but a deliberately slow, salted KDF — scrypt or pbkdf2 in the async form, with a per-password salt from a CSPRNG and a cost factor you raise over time. When verifiers can’t be trusted with signing power, switch from HMAC to asymmetric signatures (crypto.sign/verify or createSign/createVerify with an Ed25519 or RSA key pair): sign with the private key, let anyone verify with the public one. Generate every secret, salt, and ID with crypto.randomBytes or crypto.randomUUID, never Math.random, which is not a CSPRNG. And compare any two secrets with crypto.timingSafeEqual on equal-length Buffers, never === or ==, because early-exit comparison leaks timing that lets an attacker recover the secret byte by byte. Right primitive, right call — the wrong one teaches a habit that quietly ships a vulnerability. Now when you see sha256(password) or === comparing a token in a PR, you know exactly which two lines to flag and why each one breaks under attack.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.