Skip to content
DERKONLINE

Hash Passwords With Scrypt and Timing-Safe Comparison

The memory-hard scrypt setup and constant-time comparison that keep a stolen user database from becoming an overnight breach.

Derrick S. K. Siawor7 min read

The worst day of a startup's life is the day its user database leaks and the passwords inside it are immediately useful to whoever took it. That is the difference between a bad headline and a catastrophe: if the passwords are hashed properly, attackers get a pile of expensive-to-crack noise. If they are stored as plain text, reversible encryption, or fast unsalted hashes like MD5 or SHA-256, every account is compromised the moment the dump hits a forum.

To hash passwords safely in Node, use a memory-hard key derivation function rather than a fast hash like SHA-256: prefer Argon2id when available, otherwise scrypt, which ships built into Node's crypto module. Salt every password, store the parameters with the hash, and compare with crypto.timingSafeEqual so the comparison time never leaks whether a guess was close.

Password hashing done right is not complicated, but it is exact. The two things that matter are using a memory-hard key derivation function with sane cost parameters, and comparing hashes in constant time so you do not leak information through how long the comparison takes. Node ships everything you need for this in the built-in crypto module, no third-party package required.

Why a "memory-hard" function and not a fast hash

The instinct of a developer who has not thought about this is to reach for SHA-256. It is a cryptographic hash, it is fast, it is right there. That speed is exactly the problem. A modern GPU can compute billions of SHA-256 hashes per second, so an attacker with your salted SHA-256 database can brute-force common passwords at a staggering rate. Cryptographic hashes are designed to be fast. Password hashes need to be deliberately, tunably slow.

That is what a password-specific key derivation function gives you. Argon2id is the current first choice when it is available to you. When it is not, OWASP recommends scrypt, and scrypt has a real advantage in the Node world: it is built into the standard library, so there is no native dependency to compile and no package to keep patched. That is why a lot of production Node apps reach for crypto.scrypt. The hash is one half of a credential's life; the other is the session it issues, which is where JWTs attackers cannot forge or replay take over.

Scrypt is memory-hard, meaning it deliberately requires a large amount of memory to compute, not just CPU time. Memory is expensive to parallelise on the custom hardware attackers use, so memory-hardness blunts the GPU and ASIC advantage in a way that pure CPU-cost functions do not. An attacker who could try billions of fast hashes per second is reduced to a tiny fraction of that against a properly-parameterised scrypt hash.

The parameters that actually matter

Scrypt has three cost parameters, and getting them right is most of the job.

  • N, the CPU and memory cost factor. Must be a power of two. OWASP's current recommendation is 2^17 (131072).
  • r, the block size. OWASP recommends 8.
  • p, the parallelisation factor. OWASP recommends 1.

Those values (N=131072, r=8, p=1) use roughly 128MB of memory per hash. That memory cost is the point. It is trivial for your server to spend that on a single login, and brutally expensive for an attacker to spend it across billions of guesses.

The salt is the other non-negotiable. Generate a random salt of at least 16 bytes per password, with a cryptographically secure random source, and store it alongside the hash. The same constant-time and signature discipline carries over to verifying payment webhooks before they move money, where a forged signature is the equivalent of a forged login. The salt is why two users with the same password get different hashes, which defeats precomputed rainbow tables and stops an attacker from cracking one password and unlocking every account that shares it. The salt is not a secret, it is stored in the clear next to the hash. Its job is uniqueness, not concealment.

Hashing on signup

The flow is: generate a salt, derive a key with scrypt, and store everything you need to verify later in one record. Store the algorithm and its parameters too, because you will want to raise the cost factors over the years as hardware gets faster, and you cannot verify an old hash if you have forgotten what parameters produced it.

Scrypt signup hashing flow and login verification with length check then timingSafeEqual compare

import { randomBytes, scrypt } from "node:crypto";
import { promisify } from "node:util";
const scryptAsync = promisify(scrypt);

const N = 131072, r = 8, p = 1, keyLen = 64;

async function hashPassword(password) {
  const salt = randomBytes(16);
  const derived = await scryptAsync(password, salt, keyLen, { N, r, p, maxmem: 256 * 1024 * 1024 });
  return `scrypt$N=${N},r=${r},p=${p}$${salt.toString("hex")}$${derived.toString("hex")}`;
}

Two practical notes. First, you have to raise maxmem above its default, because at N=131072 the memory requirement exceeds Node's default limit and the call will throw otherwise. Second, store the whole thing as one self-describing string. The format above carries the algorithm, the parameters, the salt, and the derived key, so verification needs nothing but the stored value and the submitted password.

Verifying without leaking timing

This is the part people get wrong even when the hashing is right. To check a login, you parse the stored record, re-derive the key from the submitted password using the stored salt and parameters, and compare it to the stored key. The compare is where a subtle vulnerability lives.

If you compare with === or a normal byte-by-byte loop that returns early on the first mismatch, the comparison takes slightly longer when more leading bytes match. An attacker measuring response times across many attempts can use that timing difference to recover the hash one byte at a time. It is a real, demonstrated class of attack, not a theoretical one.

The defence is constant-time comparison. Node's crypto.timingSafeEqual compares two buffers in time that does not depend on where they differ, so the timing reveals nothing. It only accepts Buffers, and it throws if the two buffers are different lengths, so you check the length first, before calling it.

import { scrypt, timingSafeEqual } from "node:crypto";
import { promisify } from "node:util";
const scryptAsync = promisify(scrypt);

async function verifyPassword(stored, password) {
  const [, paramStr, saltHex, keyHex] = stored.split("$");
  const params = Object.fromEntries(paramStr.split(",").map(kv => kv.split("=")));
  const salt = Buffer.from(saltHex, "hex");
  const expected = Buffer.from(keyHex, "hex");
  const derived = await scryptAsync(password, salt, expected.length, {
    N: +params.N, r: +params.r, p: +params.p, maxmem: 256 * 1024 * 1024,
  });
  if (derived.length !== expected.length) return false;
  return timingSafeEqual(derived, expected);
}

The length check before timingSafeEqual is not optional. Without it, a mismatched length throws an exception, and an exception path that differs from the normal path is itself a timing and behaviour leak.

The details that separate "hashed" from "safe"

A few more things turn a correct hash into a system that actually holds up:

  • Return the same error for wrong-email and wrong-password. "Invalid email or password" for both. If you say "no such user" versus "wrong password," you have handed attackers a way to enumerate which emails have accounts, the exact failure mode covered in preventing user enumeration in your login and reset flows. Same message, same response time, every time.
  • Rate-limit login attempts. Even a perfect 128MB-per-hash scrypt does not stop someone hammering one account with the top thousand passwords if you let them try unlimited times. Pair the hash with per-IP and per-account rate limits and progressive lockout that locks out credential stuffers.
  • Never log the password, the hash, or the salt. Not in debug output, not in error reports, not in request logging middleware that happens to capture the body. The same instinct keeps you from shipping API keys and secrets in your frontend bundle and reminds you to mask PII in public API responses by default.
  • Plan to raise the cost. Hardware gets faster, so the OWASP-recommended N will climb over the years. Because you stored the parameters with each hash, you can rehash a user's password at their next successful login with stronger parameters, gradually upgrading the whole database without forcing a reset.

When a database does leak despite all this, an answer to "which accounts and when" is what turns a panic into a contained response, and audit logs that actually help after a breach are what give you that answer. This is the baseline we hold on every authentication flow we build, and it is the kind of thing that is invisible when it works and unforgivable when it is missing. If you want someone to look at how your app stores credentials before an incident forces the question, our free security scan is a fast first pass, and a full security audit goes through the auth flow line by line. A leaked database is survivable. A leaked database full of plain-text or fast-hashed passwords usually is not.