Password storage has one goal that shapes every decision: when your users table leaks (assume it will, through a backup, a SQL injection or a misconfigured replica), the attacker should have to spend real money per guess, per user. Encryption doesn’t achieve that, because whoever steals the table often steals the key too. A fast hash like SHA-256 doesn’t either, since a single modern GPU computes billions of SHA-256 hashes per second. What you want is a function that’s deliberately expensive to compute and gives each user’s hash its own starting point.
Node’s built-in crypto module provides one: crypto.scrypt. Everything below ran on Node 22.23 with OpenSSL 3.5.
Why slow, memory-hard and salted
Three properties, each defending against a different attack.
Slow. An honest server checks one password per login, so taking a quarter of a second costs almost nothing. An attacker testing a dictionary of 100 million guesses against one leaked hash pays that quarter second 100 million times. The cost parameter is how you set the price.
Memory-hard. Attackers don’t use CPUs; they use GPUs and custom hardware with thousands of small cores. scrypt requires a large block of memory per computation (128 MiB with the parameters below), and memory is the thing those cores lack. That’s what separates scrypt and Argon2 from older slow functions such as PBKDF2, which is CPU-bound only.
Salted. A salt is a random value mixed into each hash. It doesn’t need to be secret. Its job is to make identical passwords produce different hashes, so an attacker can’t precompute a table of common passwords once and look up every user, and can’t see that 3,000 of your users share the same hash and therefore the same password. One salt per password, freshly generated, never one salt for the whole application.
The common wrong answers interviewers listen for: “we use SHA-256 with a salt” (salted, but fast), “we encrypt the passwords” (reversible, so a key leak exposes all of them), and “we hash it on the client” (then the hash is the password, from the server’s point of view).
A hash format you can verify later
Here’s a complete implementation. The stored string carries everything verification needs:
import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';
import { promisify } from 'node:util';
const scryptAsync = promisify(scrypt);
const PARAMS = { N: 2 ** 17, r: 8, p: 1, maxmem: 256 * 1024 * 1024 };
const KEYLEN = 32;
export async function hashPassword(password) {
const salt = randomBytes(16);
const key = await scryptAsync(password.normalize('NFKC'), salt, KEYLEN, PARAMS);
return ['scrypt', PARAMS.N, PARAMS.r, PARAMS.p,
salt.toString('base64'), key.toString('base64')].join('$');
}
export async function verifyPassword(password, stored) {
const [alg, N, r, p, saltB64, keyB64] = stored.split('$');
if (alg !== 'scrypt') throw new Error(`unsupported hash: ${alg}`);
const expected = Buffer.from(keyB64, 'base64');
const actual = await scryptAsync(
password.normalize('NFKC'),
Buffer.from(saltB64, 'base64'),
expected.length,
{ N: Number(N), r: Number(r), p: Number(p), maxmem: PARAMS.maxmem },
);
return timingSafeEqual(actual, expected);
}Running it:
hash took 261 ms
scrypt$131072$8$1$6mGKCHTvT+Lj/DOYsGlxbA==$+zEnHqv7uOgyw+ZzK7IymlKTSAcD0h5PK9tzCmBJ+40=
again : scrypt$131072$8$1$qYGuh4yt3e2ElfDhWDJjUA...
right : true
wrong : falseHashing the same password a second time produced a different salt and therefore a different string, which is the salt doing its job. The wrong password differs only in its first letter’s case, and verification rejects it.
Each decision in that code deserves a sentence in an interview:
-
N: 2 ** 17, r: 8, p: 1are the minimum scrypt parameters in OWASP’s Password Storage Cheat Sheet.Nis the CPU/memory cost and must be a power of two; memory use is roughly128 * N * rbytes, so these values need 128 MiB per hash. On my laptop that took 261 ms. Benchmark on your production hardware and aim for something that keeps login latency acceptable. -
maxmemmust be raised. Node’s default limit is 32 MiB, and without the option the call fails immediately:ERR_CRYPTO_INVALID_SCRYPT_PARAMS - Invalid scrypt params: error:030000AC:digital envelope routines::memory limit exceededText -
Parameters live in the stored string. In two years you’ll want a higher
N. Because each hash records its own cost, old hashes still verify with the parameters they were made with, and you can rehash on the user’s next successful login. Thescrypt$prefix leaves room to switch algorithms the same way. -
normalize('NFKC')makes sure “é” typed as one code point on a phone and as “e” plus a combining accent on a laptop derive the same key. Pick a normalisation form once and never change it, or existing users can’t log in. -
16 random bytes of salt from
randomBytes, a cryptographically secure generator.Math.random()is not one.
Comparing in constant time
timingSafeEqual compares two buffers in time that doesn’t depend on where they first differ. A normal === or Buffer.equals can return as soon as it finds a mismatched byte, so an attacker measuring response times very precisely could, in principle, learn how many leading bytes of their guess were right. With a salted, slow KDF that leak is hard to exploit, since the attacker doesn’t control the derived bytes, but constant-time comparison is cheap and the expected habit for anything secret: hashes, HMAC signatures, API tokens.
One trap: timingSafeEqual throws when the lengths differ, with ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH. The code above avoids it by deriving exactly expected.length bytes. If you compare user-supplied tokens, check lengths first and return false, otherwise a malformed input becomes a 500 error.
Timing also leaks at a higher level. If your login handler returns “unknown user” in 2 ms and “wrong password” in 260 ms, the latency tells an attacker which emails have accounts. Run a dummy verification against a fixed hash when the user doesn’t exist, and return the same message for both cases.
What scrypt does to your event loop
crypto.scrypt is asynchronous: the work runs on libuv’s thread pool, not the main thread, so a slow hash doesn’t freeze other requests. Don’t use scryptSync in a request handler; it blocks the event loop for the full quarter second.
The thread pool is small, though. It has four threads unless you set UV_THREADPOOL_SIZE before startup, and it’s shared with fs, dns.lookup and zlib. Firing eight hashes at once:
8 concurrent hashes, slowest 571 ms; UV_THREADPOOL_SIZE default is 4Four ran, four waited, so the slowest took about two hashing times. Under a burst of logins, latency grows in steps of 260 ms, and file reads and DNS lookups queue behind the hashes too. That’s also a denial-of-service angle: an attacker posting to /login as fast as they can is making you spend 128 MiB and a quarter second of CPU per request. Rate limiting by account and by IP isn’t optional on an endpoint like this. If login volume is high, raise UV_THREADPOOL_SIZE towards your core count, or run authentication in a separate service.
scrypt, Argon2id, bcrypt
OWASP currently lists Argon2id first, scrypt second, and bcrypt for legacy systems. Argon2id isn’t in Node 22’s crypto module (typeof crypto.argon2 is 'undefined' on 22.23), so using it means a native package such as argon2. That’s a perfectly reasonable choice; scrypt’s advantage is that it needs no dependency. bcrypt is fine where it’s already deployed, but note it silently ignores everything after the 72nd byte of the password. PBKDF2 is acceptable when compliance requires it (FIPS environments), with a very high iteration count, since it isn’t memory-hard.
Interview exercise: upgrading legacy hashes
“You inherit a Node service whose users table stores sha256(password) in hex, no salt, for 400,000 users. You can’t force everyone to reset their password. How do you migrate to scrypt, and what do you do on day one?”
Answer. Day one, I treat the existing hashes as already compromised in practice: unsalted SHA-256 falls to precomputed tables and GPU cracking quickly, so the most urgent step is to stop it being plain SHA-256 at rest.
I’d wrap every existing hash immediately, without waiting for logins: compute scrypt(sha256hex, newSalt) for each row and store it as something like scrypt-sha256$131072$8$1$salt$key. That’s a one-off batch job (about 400,000 × 0.26 seconds, so I’d parallelise it across several workers, roughly 29 hours of single-threaded CPU). After it runs, no plain SHA-256 value is left in the table. Verification for those rows computes sha256(candidate) first and then the scrypt check. On each successful login, while I briefly hold the plaintext, I rehash it directly with scrypt and replace the row with the plain scrypt$ format, so the legacy layer fades away.
New registrations use the normal scrypt path from the first deploy. I’d also make sure the old hex column is deleted, not kept “just in case”, and that backups containing it are aged out. And I’d say clearly that the wrapped hashes are only as strong as the users’ passwords: a weak password is still guessable, just far more expensive to guess, so this is a good moment to add a breached-password check at login and on change.