Ch. 6 · Node.js

Node.js Password Hashing with scrypt

Store passwords as salted hashes with a slow key-derivation function, and compare candidates in constant time.

~2 min readadvancedupdated Oct 5, 2026

Passwords must never be stored in plaintext or with a fast hash. Use a slow, salted key-derivation function such as scrypt, bcrypt or argon2, and compare the derived bytes in constant time so the comparison itself does not leak information.

Before you start

You should be comfortable with the node:crypto module and async APIs. This article uses scrypt; the reasoning applies to the other KDFs.

Step-by-step walkthrough

Step 1: Salt and slow the hash

Generate a random salt per password and derive the hash with scrypt, which is deliberately slow and memory-hard. The salt ensures two users with the same password get different hashes, and the cost parameters make brute force expensive. Store the salt and parameters alongside the hash.

Step 2: Compare in constant time

Use timingSafeEqual to compare the derived bytes. A normal === comparison can short-circuit on the first differing byte, which leaks timing information. Constant-time comparison removes that side channel.

Step 3: Tune parameters and upgrade later

Choose cost parameters that take a few hundred milliseconds on your hardware, and store the algorithm and parameters with the hash so you can raise them over time. On a successful login, re-hash with the current parameters if the stored ones are outdated.

Worked scenario

The helper hashes with a per-user salt and verifies in constant time.

import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';

function hashPassword(password) {
  const salt = randomBytes(16);
  const hash = scryptSync(password, salt, 64);
  return `${salt.toString('hex')}:${hash.toString('hex')}`;
}
function verify(password, stored) {
  const [saltHex, hashHex] = stored.split(':');
  const expected = Buffer.from(hashHex, 'hex');
  const candidate = scryptSync(password, Buffer.from(saltHex, 'hex'), 64);
  return expected.length === candidate.length && timingSafeEqual(expected, candidate);
}
console.log(verify('s3cret', hashPassword('s3cret'))); // true
JavaScript

Walk through the example

hashPassword produces salt:hash, so the salt travels with the record. verify re-derives with that salt and compares the bytes in constant time. The length check avoids a timingSafeEqual exception when the stored hash is malformed, and a wrong password fails the comparison without short-circuit leaking.

Common mistake

Using a fast hash such as SHA-256, which is unsuitable for passwords, or comparing hashes with ===. Another is a global salt, which lets one rainbow table attack every user at once.

Verify the behavior

Assert that hashing the same password twice yields different stored values because the salts differ, and that both verify to true. Assert a wrong password returns false. Confirm verification time is dominated by the KDF, not the comparison.

Interview exercise

Why is a slow hash better for passwords even though it is worse for general hashing?

Answer and reasoning

A password has low entropy, so an attacker brute-forces by trying candidate passwords. A slow, memory-hard function makes each attempt costly, multiplying the time to test a large candidate set. For data integrity, where inputs are large and random, speed is the goal, which is why the two use cases need different functions.

Continue learning

Compare secret handling in Git secret management and Node process environment. Read the Node.js crypto documentation and try the Node.js interview questions.

More in Node.js

read ✓Node.js · mid

Node.js HTTP Security Headers

Set defensive HTTP headers centrally: content type protection, a content security policy, referrer policy and HSTS over HTTPS.

~2 min readread →
read ✓Node.js · mid

Node.js URL and URLSearchParams

Build and parse URLs with the WHATWG URL API instead of string concatenation, and validate the host before fetching.

~2 min readread →
esc