Password hashing: why does salted sha256 still break when the database leaks? Intermediate
Estimated reading time: 10 min read
The problem
Section titled “The problem”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?
What to choose? (summary first)
Section titled “What to choose? (summary first)”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 |
Core concepts
Section titled “Core concepts”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.
How it works
Section titled “How it works”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:
POST /signupSign-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.
Operational details
Section titled “Operational details”Node 24’s API:
crypto.argon2exists since v24.7.0, Stability 1.2 (release candidate).memoryis in KiB (19 MiB is19456);nonce(the salt) should be random, ≥ 16 bytes.crypto.scrypt:maxmemdefaults to 32 MiB and errors when roughly128 × N × r > maxmem. OWASP’s parameters need 128 MiB: raisemaxmem, don’t lowerN.- Use the async versions (
argon2,scrypt,pbkdf2): they run on the libuv threadpool, 4 threads by default. The*Syncversions 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).
Real-world example
Section titled “Real-world example”The demo lives in examples/src/password-hashing/. Measurements vary between runs:
npx --prefix examples ts-node examples/src/password-hashing/bench.tsnpx --prefix examples ts-node examples/src/password-hashing/rehash-demo.tsnode v24.17.0, CPU Apple M5, 1 threadhashing method ms/hash tries/sec/core extra RAM (MiB)sha256(pw) 0.00045 2,231,823 ~0sha256(salt+pw) 0.00043 2,338,263 ~0pbkdf2-sha256 600k 45.1 22 0scrypt N=2^17,r=8,p=1 156.0 6 128argon2id m=19456,t=2,p=1 12.4 80 19argon2id m=47104,t=1,p=1 15.6 64 46argon2 async x16 : 66 ms totalReading the output:
- “Extra RAM” = the child process’s maxRSS minus the RSS before hashing; it matches
mand128 × N × r. PBKDF2 needs almost no RAM, so GPUs parallelize it well. - The SHA-256 figures include the overhead of
createHashin JS; GPUs are far faster still.
1. Sample vector from the PHC spec (hunter2, secret = pepper): matches spec: true without pepper matches spec: false3. 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: argon2id5. timingSafeEqual different length : ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTHStep 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.
Trade-offs
Section titled “Trade-offs”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
Production checklist
Section titled “Production checklist”- Use argon2id with current OWASP parameters; record the lookup date in the code.
- Measure
ms/hashon production hardware; choose the highest parameters that peak login load can bear, under one second. - Store PHC strings; have a
needsRehashfunction that runs after every successful verification. - Call the async versions; cap concurrent hashes and rate limit per account and IP.
- Set a maximum password length; with bcrypt, cap at 72 bytes.
- Old hashes: wrap them now with a migration, count the rows still on the old scheme, set a deletion deadline.
- Keep the pepper (if used) in a secret manager; never log passwords.
Common mistakes
Section titled “Common mistakes”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 |
Self-check
Section titled “Self-check”- A salt defeats rainbow tables; why doesn’t it make brute-forcing one user any slower?
- Why did 16 concurrent argon2id hashes take 66 ms rather than 12 ms?
- Why does
needsRehashonly run after verification succeeds? - Does wrapping with
argon2id(sha256_old)need the original password, and why must it still be replaced withargon2id(pw)?
Summary
Section titled “Summary”References
Section titled “References”- OWASP — Password Storage Cheat Sheet (accessed 2026-10-24): parameters, salt, pepper, upgrading old hashes, bcrypt pre-hashing.
- Node.js v24 — Crypto:
argon2parameters,scrypt’smaxmem,timingSafeEqual. - C2SP — PHC String Format: syntax, B64, argon2id sample vectors.
- RFC 9106 — Argon2: memory-hard design and its variants.