Skip to content
Knowledge Hub

Password hashing: why does salted sha256 still break when the database leaks? Intermediate

Estimated reading time: 10 min read

A dump of a shop’s users table has leaked. The old system stored sha256(salt + password), with an 8-byte salt per user. The dev team believed a salt was enough.

On a single CPU core of a dev machine (Apple M5, Node 24.17), the demo measured about 2.3 million sha256(salt+pw) computations per second; GPUs are far faster still. Common passwords such as Mua-sam-2024 fall very quickly.

How should passwords be stored so that a dump is nearly useless, and how do you migrate millions of old hashes without forcing everyone to change their password?

Parameters are taken from the OWASP Password Storage Cheat Sheet, accessed 2026-10-24:

Situation Choose Condition
New system Argon2id m=19456 (19 MiB), t=2, p=1 Or an equivalent configuration: 46 MiB/t=1, 12 MiB/t=3, 9 MiB/t=4, 7 MiB/t=5, all p=1
No Argon2id available scrypt N=2^17, r=8, p=1 (128 MiB) Equivalents: N=2^16/p=2, 2^15/p=3, 2^14/p=5, 2^13/p=10
FIPS-140 required PBKDF2-HMAC-SHA256 600,000 iterations HMAC-SHA512: 220,000; HMAC-SHA1: 1,400,000, legacy only
Legacy already on bcrypt bcrypt work factor ≥ 10 Passwords up to 72 bytes
DB still has SHA-256/MD5 Wrap now with argon2id(sha256_old), rehash on login Record the scheme on each row

SHA-256 is designed to be fast. Someone holding the dump guesses offline: no rate limit, no lockout. Hashing speed is guessing speed.

Component Protects against Does not protect against Stored where
Salt (random, one per user) Rainbow tables, spotting two users with the same password Brute-forcing each user Alongside the hash, public
Pepper (shared secret) A standalone DB dump being cracked Leaking both the DB and the secret Outside the DB: KMS, secret manager
Work factor (t, iterations) Guessing speed on CPU GPUs with thousands of parallel cores In the PHC string
Memory-hard (m, N·r) The GPU/ASIC advantage: every attempt needs lots of RAM Passwords that are too weak In the PHC string

A rainbow table is a precomputed hash → password table; a per-user salt makes it useless. A salt does not make each guess slower; that is the job of the work factor and memory.

Argon2 and scrypt are memory-hard functions: each hash must keep a large array in RAM. GPUs have many cores but little RAM per core, so the number of parallel attempts is limited by memory.

A pepper is optional; crypto.argon2 takes it via the secret parameter. Changing the pepper requires the original password, so store a version id with it (PHC has a keyid parameter).

PHC string: storing parameters with the hash

Section titled “PHC string: storing parameters with the hash”
$argon2id$v=19$m=19456,t=2,p=1$vCDiwTb/NCC+X03gkja2jg$RCbALSwq0iAYJoiEggLTiN/AJO8PdP7/0cj3oNRdsGo
algorithm version parameters salt (B64, 16 bytes) hash (B64, 32 bytes)

B64 is standard base64 without = padding. Because the parameters are in the string, old and new hashes coexist in one column; code reads each row’s parameters to verify, and knows which rows need upgrading.

Registration and login for user Lan, following the rehash-demo.ts demo. Lan’s hash was created when the policy was still m=4096, t=3:

Login reads parameters from the PHC string, compares with timingSafeEqual, rehashes when parameters are outdated
Login reads parameters from the PHC string, compares with timingSafeEqual, rehashes when parameters are outdatedStep 1/8: Sign-up: Lan sends her password over TLS. The API neither stores nor logs this value.Clientemail + passwordAuth APIparse PHCrandomBytes16-byte saltargon2idm, t, puserspassword_hashtimingSafeEqual32 bytesPOST /signup
Client → Auth APIPOST /signup
Step 1/8

Sign-up: Lan sends her password over TLS. The API neither stores nor logs this value.

Step In the DB In the API
1–4 PHC m=19456,t=2,p=1 New salt, 32-byte hash
5–7 Old PHC m=4096,t=3 Hash again with the old parameters and salt, compare → true
8 New PHC m=19456,t=2 Rehash only after verification succeeds

Why compare with timingSafeEqual? === stops at the first differing byte, so response time leaks information. Node requires both buffers to have the same length, otherwise it throws ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH.

The login decision depends on each row’s scheme:

Loading diagram…

What about users who never log in again? Rehashing only runs when you have the original password. OWASP offers two options: wrap the old hash as input to the new algorithm (no password needed), or delete the hashes of long-inactive users and force a password reset.

Wrapping with argon2id(sha256_old) can run right away as an offline migration. OWASP notes that layered hashes may be easier to crack, so replace them with argon2id(pw) at the next login.

Node 24’s API:

  • crypto.argon2 exists since v24.7.0, Stability 1.2 (release candidate). memory is in KiB (19 MiB is 19456); nonce (the salt) should be random, ≥ 16 bytes.
  • crypto.scrypt: maxmem defaults to 32 MiB and errors when roughly 128 × N × r > maxmem. OWASP’s parameters need 128 MiB: raise maxmem, don’t lower N.
  • Use the async versions (argon2, scrypt, pbkdf2): they run on the libuv threadpool, 4 threads by default. The *Sync versions block the event loop.

Choosing parameters: the table above is the minimum. OWASP recommends hashing in under a second, tested on the production server itself: measure ms/hash, multiply by peak login load, and only then raise m or t.

DoS on the login endpoint: slow hashing is a cost for the server too. Demo: 16 concurrent argon2id hashes took 66 ms because they queue across 4 threads. Estimated from the measurements, one process handles about 320 hashes/second and needs 19 MiB per in-flight hash.

How to block it: rate limit per account and IP, cap the number of concurrent hashes, and set a maximum length for passwords (OWASP: very long passwords can cause DoS).

bcrypt and 72 bytes: bcrypt only uses the first 72 bytes; accented Vietnamese characters take 2–3 bytes in UTF-8. OWASP recommends capping at 72 bytes. The demo does not measure bcrypt (no package).

The demo lives in examples/src/password-hashing/. Measurements vary between runs:

Terminal window
npx --prefix examples ts-node examples/src/password-hashing/bench.ts
npx --prefix examples ts-node examples/src/password-hashing/rehash-demo.ts
node v24.17.0, CPU Apple M5, 1 thread
hashing method ms/hash tries/sec/core extra RAM (MiB)
sha256(pw) 0.00045 2,231,823 ~0
sha256(salt+pw) 0.00043 2,338,263 ~0
pbkdf2-sha256 600k 45.1 22 0
scrypt N=2^17,r=8,p=1 156.0 6 128
argon2id m=19456,t=2,p=1 12.4 80 19
argon2id m=47104,t=1,p=1 15.6 64 46
argon2 async x16 : 66 ms total

Reading the output:

  • “Extra RAM” = the child process’s maxRSS minus the RSS before hashing; it matches m and 128 × N × r. PBKDF2 needs almost no RAM, so GPUs parallelize it well.
  • The SHA-256 figures include the overhead of createHash in JS; GPUs are far faster still.
1. Sample vector from the PHC spec (hunter2, secret = pepper):
matches spec: true
without pepper matches spec: false
3. Login with an old-parameter hash (m=4096,t=3): verify then rehash
wrong password: false (no rehash)
rehash: argon2id 4096/3 -> $argon2id$v=19$m=19456,t=2,p=1$f...
4. Old SHA-256 hash of a user who doesn't log in: wrap now, no password needed
scheme after login: argon2id
5. timingSafeEqual
different length : ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH

Step 1: crypto.argon2 reproduces the PHC spec’s sample vector exactly, including the pepper via secret. phc.ts is only illustrative; in production use a reviewed library and don’t roll your own crypto.

Pros

  • Each offline guess costs tens of ms and many MiB of RAM.

  • PHC strings let you raise parameters gradually.

  • A pepper adds a layer when only the DB leaks.

  • < slot="cons">
  • Login costs CPU and RAM; the endpoint becomes a DoS target.

  • Peppers are hard to rotate: you need the original password.

  • Does not save passwords that are too common; you still need MFA.

Cons

  1. Use argon2id with current OWASP parameters; record the lookup date in the code.
  2. Measure ms/hash on production hardware; choose the highest parameters that peak login load can bear, under one second.
  3. Store PHC strings; have a needsRehash function that runs after every successful verification.
  4. Call the async versions; cap concurrent hashes and rate limit per account and IP.
  5. Set a maximum password length; with bcrypt, cap at 72 bytes.
  6. Old hashes: wrap them now with a migration, count the rows still on the old scheme, set a deletion deadline.
  7. Keep the pepper (if used) in a secret manager; never log passwords.

The most common mistake when writing the verify step yourself:

Comparing hashes as strings: timing leaks, and stored parameters are ignored

Common

const ok = sha256(salt + pw).toString('hex') === row.hash;

Prefer

const { params, salt, hash } = parsePhc(row.password_hash);
const ok = timingSafeEqual(await argon2id(pw, salt, params), hash);

Why

The wrong version both uses a fast hash and compares with ===. The right version reads the parameters from the PHC string and compares in constant time.

For systems still on bcrypt, the trap lies in how people work around the 72-byte limit:

Misconception Consequence What to do
With a salt, SHA-256 is safe 2.3 million tries/second/core Slow, memory-hard hashing
Login is slow, so lower the parameters The dump is easier to crack Rate limit, cap concurrency
Rehash the whole table in a nightly job No original passwords to rehash with Wrap now, rehash on login
  1. A salt defeats rainbow tables; why doesn’t it make brute-forcing one user any slower?
  2. Why did 16 concurrent argon2id hashes take 66 ms rather than 12 ms?
  3. Why does needsRehash only run after verification succeeds?
  4. Does wrapping with argon2id(sha256_old) need the original password, and why must it still be replaced with argon2id(pw)?