Password Hashing — Why bcrypt Beats SHA-256 for Storing Passwords
"We hash passwords with SHA-256, so we're secure" is one of the most common — and most dangerous — misconceptions in web development. SHA-256 is an excellent cryptographic hash, but it was never designed for passwords, and using it (with or without a salt) leaves user accounts one database leak away from mass compromise. This article explains the economics that make fast hashes fail, what salting and work factors actually do, how bcrypt, scrypt, and Argon2 compare, and how to use bcrypt correctly in Node.js.
The Problem: Fast Hashes Meet Cheap GPUs
When a database leaks, attackers do not "reverse" the hashes — they guess. They take wordlists of billions of real passwords from past breaches, hash each guess, and compare against the stolen table. The only thing standing between a leaked hash and the original password is how many guesses per second the attacker can make:
Rough single-GPU cracking speeds (consumer hardware): MD5 tens of billions of guesses/second SHA-256 billions of guesses/second bcrypt (c=12) tens of thousands of guesses/second An 8-character lowercase+digit password (~2.8 trillion combos): SHA-256: cracked in minutes to hours bcrypt: years — per password
SHA-256 is fast because file checksums and TLS need it fast. That same property hands attackers a roughly million-fold advantage over a properly configured bcrypt. The defense is not a stronger fast hash — SHA-512 has the same flaw — it is an algorithm that is deliberately expensive to compute.
Salting: Killing Rainbow Tables and Batch Attacks
A salt is a random value generated per user and stored alongside the hash. It fixes two problems that unsalted hashes have:
- Precomputation. Without salts, attackers use rainbow tables — precomputed hash lookups — so common passwords fall instantly. A unique salt makes every user's hash unique, so precomputation is useless.
- Batch cracking. Without salts, identical passwords produce identical hashes, so one guess tests every user at once. With salts, each guess must be recomputed per user.
Important: a salt does not slow down guessing an individual password. Salted SHA-256 is still crackable at billions of guesses per second — which is why salting alone is not enough. You need salt plus a slow algorithm. bcrypt, scrypt, and Argon2 all generate and embed the salt for you automatically.
Work Factors: Security You Can Tune
Purpose-built password hashes expose a cost parameter that controls how expensive each hash is. bcrypt's cost is logarithmic — cost 12 means 2^12 internal rounds, and every +1 doubles the work for you and the attacker equally. Your server pays the price once per login; the attacker pays it billions of times. As hardware improves, you simply raise the cost — no algorithm change needed.
bcrypt vs scrypt vs Argon2
- bcrypt (1999). CPU-hard, battle-tested, available everywhere. Weaknesses: only 72 bytes of password are used, and it is not memory-hard, so GPUs still get some advantage. Still a perfectly defensible choice today.
- scrypt (2009). Adds memory-hardness — each hash needs significant RAM, which GPUs and ASICs have little of per core. Parameters (N, r, p) are more fiddly to tune than bcrypt's single cost.
- Argon2 (2015). Winner of the Password Hashing Competition and the current OWASP first choice. Tunable across time, memory, and parallelism; use the Argon2id variant. Best pick for new systems where a good library exists for your stack.
Practical guidance: new project with good Argon2 support — use Argon2id. Existing bcrypt system — keep it, keep the cost current, and do not lose sleep. The gap between bcrypt and Argon2 is tiny compared with the gap between either of them and SHA-256.
Node.js bcrypt Example with Cost Guidance
npm install bcrypt
const bcrypt = require('bcrypt');
// Registration: hash with a cost factor (salt is generated & embedded)
const COST = 12; // ~200-300ms on typical 2026 server hardware
const hash = await bcrypt.hash(plainPassword, COST);
await db.users.update(userId, { passwordHash: hash });
// Stored value looks like: $2b$12$N9qo8uLOickgx2ZMRZoMye...
// alg cost salt(22) + hash(31)
// Login: compare — never hash-and-string-compare yourself
const ok = await bcrypt.compare(inputPassword, user.passwordHash);
if (!ok) return res.status(401).json({ error: 'invalid credentials' });
// Benchmark to pick your cost
for (let cost = 10; cost <= 14; cost++) {
const t = Date.now();
await bcrypt.hash('benchmark-password', cost);
console.log('cost', cost, Date.now() - t, 'ms');
}- Target roughly 100–300 ms per hash on your production hardware; cost 10–12 is typical today
- Use the async API (
bcrypt.hash/bcrypt.compare), nothashSync, so logins do not block the event loop - Re-hash on login when a user's stored cost is below your current target — the stored hash records the cost it was created with
Common Mistakes to Avoid
- Salted SHA-256 as "good enough" — the salt stops rainbow tables, not GPU brute force. Still billions of guesses per second.
- A single global salt (a "pepper" misused) — one leak and every user shares the weakness. Salts must be per-user; a pepper is an optional extra kept out of the database, not a replacement.
- Rolling your own scheme — sha256(sha256(password)) or 1000 manual rounds is still GPU-friendly and adds subtle bugs. Use a vetted library.
- Truncation surprises — bcrypt ignores everything past 72 bytes. If you allow very long passphrases, validate length rather than silently truncating.
- Cost set once in 2018 and never revisited — hardware doubles; your cost should climb with it.
- Storing the salt "secretly" — salts are not secrets; they live next to the hash by design. The secrecy lives in the password, the slowness in the algorithm.
Frequently Asked Questions
SHA-256 is designed to be fast, and speed helps the attacker: one modern GPU computes billions of SHA-256 hashes per second, so leaked hashes fall quickly to brute force. Passwords need deliberately slow, salted, tunable algorithms like bcrypt, scrypt, or Argon2.
The highest cost your server can afford per login — commonly 10 to 12 in 2026, targeting roughly 100–300 ms per hash. Each +1 doubles the work. Benchmark on production hardware and re-evaluate every couple of years.
Argon2id is the current recommendation for new systems because its memory-hardness makes GPU attacks far more expensive. bcrypt remains solid and battle-tested; migrating existing bcrypt hashes is rarely urgent.