Password Hashing: Why MD5 and SHA-256 Both Fail
MD5 and SHA-256 hash too fast with no salt, so GPUs guess billions per second.
20+ years shipping production backend systems. Written from production experience, not tutorials.
- ✓What a hash function does
- ✓How logins check passwords
- ✓Database table basics
- MD5 and SHA-256 are built for speed, so attackers guess billions of passwords per second on cheap GPUs
- Proper password hashing must be slow, salted per user, and memory-hard to defeat parallel cracking rigs
- bcrypt with a tuned work factor remains a solid default; Argon2id is the modern first choice resisting GPU and ASIC attacks
- A pepper stored outside the database plus KMS-backed keys adds breach containment when hashes leak
- Migrate live with stacked hashes: verify the old hash once, then rehash with Argon2id transparently
Think of password storage like a safe. MD5 and SHA-256 are cheap luggage locks a thief can pick millions of times per second. bcrypt and Argon2id are bank vaults with time locks that take a noticeable moment to open, which is fine for one login but impossibly slow for guessing millions. Each vault also gets a unique serial number so thieves can't pre-build a master key. That's salting, and slowness plus uniqueness is why modern hashes survive leaks while MD5 falls in hours.
Every leaked database teaches the same lesson within days: accounts protected by MD5 or plain SHA-256 fall first, and the cracked passwords unlock email, banking, and corporate accounts reused elsewhere. The math isn't close. Fast hashes let a single GPU test billions of guesses per second, while purpose-built password hashes hold the same rig to a handful of tries.
The confusion persists because SHA-256 sounds secure. It is secure for checksums and message integrity, where speed is a virtue. Password storage needs the opposite: deliberate slowness, unique salts, and memory hunger that cripples parallel crackers. MD5 adds broken collision resistance on top; unsalted SHA-256 adds rainbow-table vulnerability. Neither belongs near credentials.
The modern answer is settled and boring: bcrypt with a calibrated cost factor, or Argon2id tuned for your hardware, each with per-user salts, plus a pepper for breach containment. Migration doesn't require a password reset for everyone; stacked transparent upgrades move users over as they log in.
This guide shows why fast hashes fail with real numbers, how to configure bcrypt and Argon2id correctly, and the migration path that gets you there without downtime. You'll leave able to defend the choice in review and spot MD5 the moment it appears in a schema.
Why Speed Kills: MD5 and SHA-256 Against GPU Rigs
General hashes optimize for throughput: checksums must fly through gigabytes, so MD5 and SHA-256 finish in nanoseconds with tiny state. Password crackers exploit exactly that. A single commodity GPU tests on the order of 20 billion MD5 guesses per second and over a billion SHA-256, sweeping common password spaces in hours. Eight-character patterns, dictionary words with rules, and previously leaked passwords all fall within a weekend.
Salts don't rescue fast hashes by themselves. A unique salt per user defeats precomputed rainbow tables, forcing per-user guessing, but each guess stays nanoseconds cheap. Attackers simply point the rig at high-value accounts first: admins, then early users, then everyone. The incident above cracked 68 percent of 120,000 rows because cheap guesses compound across the whole table.
Collision resistance is a separate MD5 failure worth naming. MD5 collisions are practical, so MD5 can't vouch for file or certificate integrity either. SHA-256 remains fine for integrity and checksums; its sin is only speed in the password role. Using the right primitive per job is the lesson, not fearing SHA-256 everywhere.
The takeaway for reviews is a one-line rule: any password verifier computable billions of times per second is broken by definition. Password storage must cost real time and memory per guess, which is what the next sections configure.
bcrypt Work Factors: Tuning Slowness You Can Feel
bcrypt's cost factor sets how slow each hash runs, doubling work with every increment. Cost 10 takes roughly tens of milliseconds, cost 12 lands near a few hundred milliseconds on current server hardware. That delay is invisible to a human logging in once and devastating to a rig attempting millions of guesses. The factor is stored with the hash, so verification automatically uses the right cost.
Tune the factor on production-grade hardware, not laptops. Benchmark hashpw across costs 10 through 13, pick the slowest value keeping logins under about 400 milliseconds at peak load, and record the choice with a review date. Revisit annually as hardware improves; bumping the factor is a config change, not a migration.
bcrypt salts automatically: each hash embeds a unique 128-bit salt, so identical passwords produce different strings and rainbow tables die. Its 72-byte password limit deserves a pre-hash for very long passphrases: HMAC with the pepper first, then bcrypt, keeps entropy without truncation surprises.
Deploy with rehash-on-login. Store the cost alongside verification logic that rehashes whenever the stored cost trails the current setting. Every login transparently upgrades one account, and dashboards show the old-cost population draining without a maintenance window.
Argon2id: Memory-Hardness Against GPUs and ASICs
Argon2id is the modern first choice because it taxes memory, not just time. Each guess must hold a large working buffer, typically tens of megabytes, which cripples GPUs and custom chips built for thousands of tiny parallel computations. CPUs handle one login easily; cracking rigs run out of RAM long before they run out of patience.
Three parameters control the defense: memory cost sets the buffer size, time cost sets the passes over it, and parallelism sets the lanes. Sensible starting points on current servers are 64 MB memory, 3 passes, and parallelism matching cores, tuned so verification lands in the same 200 to 400 millisecond band as bcrypt. Raise memory before time when hardware allows, since RAM is the scarcest attacker resource.
Libraries keep this simple. argon2-cffi and language-native bindings generate unique salts automatically, encode parameters with the hash, and expose needs_rehash checks for transparent upgrades. Store the full encoded string; it carries everything verification needs without extra columns.
Choose Argon2id for new systems and bcrypt where FIPS or legacy constraints require it. Both beat any fast hash by orders of magnitude; Argon2id's memory edge is what earns it the default recommendation today.
Salts, Peppers, and KMS: Containing the Inevitable Leak
Salts and peppers solve different halves of the leak. A salt is unique per user, stored with the hash, and stops precomputation: identical passwords hash differently, so attackers guess each account individually. Every modern scheme generates salts automatically, which is why hand-rolled salt columns are now a smell rather than a skill.
A pepper is a secret shared across the app but stored outside the database, applied before hashing. When only the hash table leaks, guesses can't even be tested without the pepper. Hold it in a KMS or HSM-backed secret, version it with identifiers for rotation, and apply it via HMAC rather than raw concatenation to avoid construction pitfalls.
KMS envelope patterns add audit and rotation. The app requests HMAC or decryption at login time, KMS logs each use, and rotation re-wraps without touching every row at once. Version identifiers in the stored record select the right pepper during stacked verification.
Layer honestly: pepper contains database-only breaches but not full-host compromise. It buys the hours needed for resets and rotation after a SQL dump, which is exactly the scenario postmortems keep describing. Pair it with slow hashing rather than treating it as a substitute.
Migration Without Downtime: Stacked Transparent Upgrades
Migration stalls on fear of flag days, but stacking removes the need. Keep the legacy verifier beside the new one: check Argon2id first, fall back to MD5 or SHA-256 once for old rows, and when the fallback succeeds, hash the just-presented password with the new scheme and store it. Each login upgrades exactly one account with zero user friction.
Order the rollout defensively. Ship stacked verification first with dashboards counting legacy rows, force resets for admins and high-risk accounts immediately, then let natural logins drain the rest. Set a deadline after which remaining legacy accounts must reset, since dormant accounts never log in to upgrade.
Details that bite: constant-time comparison on the legacy path avoids timing leaks, rate limiting survives the transition unchanged, and breach-password screening applies at reset and registration. Test the stack with accounts on each scheme plus wrong-password cases before enabling.
Watch the drain graph weekly. The incident team above cleared active accounts within days and forced the dormant tail at the deadline. Legacy-zero is the milestone that closes the postmortem, not the deploy date. Announce the deadline early so dormant owners expect the reset email.
Policy Around Hashing: Length, Screening, and Stuffing Defense
Hashing is the last line; password policy is the first. Require lengths users can meet with phrases rather than tortured complexity rules, following current NIST guidance toward minimum length and breach screening. Check new passwords against known-compromised lists at registration and reset, rejecting the 81,000-style entries crackers already own.
Assume reuse and defend the login endpoint. Rate limit by account and IP, alert on spray patterns with many accounts and few passwords, and offer phishing-resistant MFA for high-value roles. The incident's 4,300 stuffing successes came from reused passwords, not from breaking Argon2id; endpoint defenses are what stop the replay.
Communicate honestly after leaks. Tell users what was stored, what changed, and what to do about reuse elsewhere. Forced resets plus session revocation contain the damage; vague reassurance extends it.
Review the whole stack yearly: cost factors against current hardware, pepper rotation, screening list freshness, and legacy-hash counts. Password security is a maintained posture across hashing, policy, and monitoring, not a single algorithm choice. Fund the yearly review like any other security control: scheduled, owned, and evidenced with scan outputs.
An MD5 User Table Fell to 68 Percent Cracked in a Weekend
- Fast hashes plus shared salts equal plaintext with extra steps. Per-user salts and deliberate slowness are mandatory, which MD5 and plain SHA-256 can't provide.
- One table leak becomes many account takeovers through reuse. Breach-password screening and stuffing detection belong in the launch checklist, not the postmortem.
- Stacked migration upgrades hashes without a flag day. Verify legacy once, rehash transparently, and watch the legacy population drain to zero.
| File | Command / Code | Purpose |
|---|---|---|
| bcrypt_tuned.py | COST = 12 | bcrypt Work Factors |
| argon2id_hash.py | from argon2 import PasswordHasher | Argon2id |
| pepper_hash.py | from argon2 import PasswordHasher | Salts, Peppers, and KMS |
| stacked_migration.py | legacy_db = {} | Migration Without Downtime |
Key takeaways
Common mistakes to avoid
5 patternsStoring passwords with MD5 or single-round SHA-256
Reusing one site-wide salt for every account
Leaving bcrypt cost at 4 for snappy logins
Keeping the pepper in source control beside the code
Skipping breach screening and spray detection
Interview Questions on This Topic
Why is MD5 wrong for passwords even with a salt?
Frequently Asked Questions
20+ years shipping production backend systems. Written from production experience, not tutorials.
That's Crypto. Mark it forged?
5 min read · try the examples if you haven't