Hashing Passwords Correctly: Why You Need bcrypt, Not SHA-256
Using the wrong hash function for passwords has led to some of the most damaging breaches of the past decade โ cases where millions of "hashed" passwords were cracked within hours of the database being leaked. The core mistake is using fast hash functions like MD5 or SHA-256, which are designed for speed and are therefore terrible for passwords.
Table of Contents
Why Fast Hash Functions Fail
When an attacker obtains your password database, they have unlimited offline compute time. With a consumer GPU:
- 150 billion MD5 hashes per second
- 8 billion SHA-256 hashes per second
- 20+ million bcrypt hashes per second at cost factor 10 on modern hardware (2026 benchmarks) โ meaning an attacker with a GPU cluster can test massive password lists quickly if cost is too low
- 200,000 Argon2id hashes per second (moderate parameters)
At 8 billion SHA-256/sec, an attacker can try every 8-character combination of letters, numbers, and symbols in under two hours. The same effort against bcrypt would take over a year. bcrypt is designed to be slow, and its slowness is tunable.
What Salting Does (and Doesn't)
A salt is a random per-user value concatenated with the password before hashing. Salting prevents rainbow table attacks (precomputed hash lookups) and prevents identifying users with identical passwords. But it doesn't stop brute-force attacks โ that's where slow hashing comes in. bcrypt, Argon2, and scrypt handle salting automatically. Never implement your own salting scheme on top of SHA-256.
bcrypt: The Battle-Tested Choice
// Node.js
import bcrypt from 'bcrypt';
const hash = await bcrypt.hash(password, 12); // cost factor 12
const isValid = await bcrypt.compare(loginPassword, hash);
# Python
import bcrypt
hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12))
is_valid = bcrypt.checkpw(login_password.encode(), hashed)
The stored bcrypt hash includes the algorithm, cost factor, salt, and hash in a single string โ no separate salt storage needed. Aim for a hash time of 100โ300ms on your production hardware. Start with cost factor 12; increase as hardware improves.
Argon2id: The Modern Recommendation
Argon2 won the Password Hashing Competition in 2015 and is recommended by OWASP and NIST for new applications. It's memory-hard โ requiring configurable RAM per hash โ making it significantly more expensive to attack with GPUs than bcrypt.
// Node.js
import argon2 from 'argon2';
const hash = await argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 65536, // 64 MB
timeCost: 3,
parallelism: 4
});
const isValid = await argon2.verify(hash, loginPassword);
# Python
from argon2 import PasswordHasher
ph = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=4)
hash = ph.hash(password)
try:
ph.verify(hash, login_password) # raises on mismatch
except: pass
PBKDF2: For Regulated Environments
PBKDF2 is FIPS 140-2 approved and required in some regulated industries. NIST recommends at least 310,000 iterations with HMAC-SHA256.
# Python โ PBKDF2 with SHA-256
import hashlib, os
salt = os.urandom(32)
key = hashlib.pbkdf2_hmac('sha256', password.encode(), salt, 310000)
Decision Matrix
| Situation | Recommendation |
|---|---|
| New app, no compliance requirements | Argon2id |
| Existing bcrypt infrastructure | bcrypt (cost 12+) |
| FIPS-compliant environment | PBKDF2-SHA256 (310,000+ iterations) |
| Legacy MD5/SHA-256 passwords | Migrate to bcrypt/Argon2 on next login |
Work Factor and Timing
The "work factor" or "cost" parameter in bcrypt and Argon2 controls how long a single hash takes to compute. The goal is to make each hash slow enough that brute-forcing millions of passwords becomes impractical, but fast enough that legitimate login requests don't feel sluggish. A good target is 100-250 milliseconds per hash on your production hardware. For bcrypt, a cost factor of 12 typically hits this range on modern servers. For Argon2id, the recommended minimum is 64 MB of memory and 3 iterations.
Critically, you need to benchmark on your actual production hardware, not your development laptop. A cost factor that takes 200ms on a beefy server might take 2 seconds on a shared cloud instance. And you need to revisit this annually โ hardware gets faster, and your cost factor should increase to compensate. When you increase the cost factor, re-hash users' passwords at their next successful login so the database gradually migrates to the stronger setting.
Common Implementation Mistakes
Even developers who choose the right algorithm make implementation errors. The most dangerous is truncating the password before hashing. bcrypt has a 72-byte input limit โ if your users have long passwords, bytes beyond 72 are silently ignored. This means a 100-character password and the same password truncated to 72 characters produce identical hashes. If this matters for your application, pre-hash the password with SHA-256 before passing it to bcrypt.
Another frequent mistake is comparing hashes with standard string equality (==) instead of a constant-time comparison function. Standard comparison short-circuits on the first mismatched character, which leaks timing information an attacker can exploit. Use crypto.timingSafeEqual() in Node.js, hmac.compare_digest() in Python, or the equivalent in your language. Most bcrypt libraries handle this internally in their verify function, which is why you should always use the library's verify method rather than hashing and comparing manually.
Finally, never store the hash algorithm or cost factor separately from the hash string. bcrypt and Argon2 both encode this information in the hash output itself (the $2b$12$... prefix in bcrypt, for example). This self-describing format means you can verify passwords hashed with different cost factors without maintaining a lookup table, and it makes cost factor migration seamless.
Pepper: An Extra Layer
A pepper is a secret key mixed into the password before hashing, stored separately from the database (typically in environment variables or a secrets manager). Unlike a salt โ which is unique per password and stored alongside the hash โ the pepper is the same for all passwords and never stored in the database. This means that even if an attacker dumps your entire database, they still can't crack any passwords without the pepper, because the input to the hash function includes a value they don't have.
The downside is operational: if you lose the pepper, every user's password becomes unverifiable and you'll need to force a password reset for your entire user base. Store the pepper in a secrets manager with proper backup procedures, and consider using an HSM (Hardware Security Module) for the highest security requirements.
