Home › Security › Password Hashing: Why MD5 and SHA-256 Both Fail
Beginner 5 min · September 23, 2026

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.

N
Naren Founder & Principal Engineer

20+ years shipping production backend systems. Written from production experience, not tutorials.

Follow
✓ Production
production tested
September 26, 2026
last updated
2,085
articles · all by Naren
Before you start⏱ 13 min
  • ✓What a hash function does
  • ✓How logins check passwords
  • ✓Database table basics
 ● Production Incident 🔎 Debug Guide
⚡Quick Answer
  • 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
✦ Definition~90s read
What is Password Hashing?

Password hashing converts a password into a verifier stored in the database, designed so theft of the verifiers doesn't yield the passwords. Three properties separate password hashes from general hashes. Slowness makes each guess expensive; salts make each user's hash unique so precomputed tables fail; memory-hardness forces crackers to spend RAM per guess, defeating GPUs and custom chips that excel at parallel simple math.

★
Think of password storage like a safe.

MD5 fails on all three counts plus collision resistance. It hashes in nanoseconds, carries no salt by itself, and uses trivial memory, so billions of guesses per second are routine. SHA-256 is a sound general hash misapplied: fast by design, unsalted unless you add it, and not memory-hard.

Even salted SHA-256 falls to GPU rigs because each guess stays cheap; iteration schemes like PBKDF2 improve this but still lack memory-hardness.

bcrypt adapts through its cost factor: each increment doubles hashing time, tracking hardware over the years. It salts automatically and has survived decades of cryptanalysis, making it a trustworthy default. Argon2id, the widely endorsed modern choice, adds memory-hardness with tunable memory, time, and parallelism parameters, resisting GPU and ASIC crackers that breeze through bcrypt's lighter memory use.

Pepper, a secret stored outside the database and applied via HMAC or prepended before hashing, means leaked hashes alone aren't crackable without the separate secret.

Migration uses stacking, not flag days. Store the new hash alongside verification logic that checks Argon2id first, falls back to the legacy hash once, and on success rehashes the presented password with the new scheme. Every login transparently upgrades one account, and the legacy population drains without mass resets.

Plain-English First

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.

📊 Production Insight
The weekend crack above needed no exotic hardware: commodity GPUs at billions of guesses per second. Symptom: MD5 or single-round SHA-256 in the users table. Rule: treat fast password hashes as plaintext-equivalent and schedule migration immediately.
🎯 Key Takeaway
Fast hashes let rigs guess billions per second, so salted speed still falls. Password storage must be deliberately slow and memory-hungry.

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.

bcrypt_tuned.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import bcrypt
import time

COST = 12

def hash_password(password: str) -> str:
    salt = bcrypt.gensalt(rounds=COST)  # unique 128-bit salt embedded
    return bcrypt.hashpw(password.encode('utf-8'), salt).decode()

def check(password: str, stored: str) -> bool:
    return bcrypt.checkpw(password.encode('utf-8'), stored.encode())

def needs_upgrade(stored: str) -> bool:
    # Rehash accounts still on older, faster costs at next login.
    return int(stored.split('$')[2]) < COST

t0 = time.perf_counter()
stored = hash_password('correct horse staple')
print(f'hash took {time.perf_counter() - t0:.2f}s; ok={check("correct horse staple", stored)}')
📊 Production Insight
Cost 4 in production feels instant to users and to crackers alike. Symptom: logins completing in single-digit milliseconds. Rule: benchmark to 200 to 400 milliseconds per hash and rehash-on-login stragglers.
🎯 Key Takeaway
Set bcrypt cost so logins take a few hundred milliseconds. Automatic salts plus annual cost reviews keep it strong as hardware improves.

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.

argon2id_hash.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError

# Memory-hard defaults: 64 MiB, 3 passes, parallelism 4.
ph = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=4)

def hash_password(password: str) -> str:
    return ph.hash(password)  # salt + params embedded in output

def check(password: str, stored: str) -> bool:
    try:
        return ph.verify(stored, password)
    except VerifyMismatchError:
        return False

stored = hash_password('correct horse staple')
print('argon2id ok:', check('correct horse staple', stored))
print('upgrade due:', ph.check_needs_rehash(stored) is True)
📊 Production Insight
Memory-hardness is why the migrated table above resists the rig that cracked MD5 in a weekend. Symptom: choosing schemes by name familiarity. Rule: default to Argon2id for new code; keep bcrypt where compliance demands it.
🎯 Key Takeaway
Argon2id forces RAM per guess, defeating parallel crackers. Tune memory first, keep logins near 300 milliseconds, and store the encoded string.

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.

pepper_hash.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import hmac
import os
from argon2 import PasswordHasher

# In production load PEPPER from KMS/Vault at boot, never from source control.
PEPPER = os.environ.get('PEPPER_V1', 'dev-only-pepper').encode()
ph = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=4)

def hash_password(password: str) -> str:
    mac = hmac.new(PEPPER, password.encode(), 'sha256').hexdigest()
    return 'v1$' + ph.hash(mac)

def check(password: str, stored: str) -> bool:
    version, _, inner = stored.partition('$')
    assert version == 'v1'
    mac = hmac.new(PEPPER, password.encode(), 'sha256').hexdigest()
    try:
        return ph.verify(inner, mac)
    except Exception:
        return False

s = hash_password('correct horse staple')
print('peppered ok:', check('correct horse staple', s))
📊 Production Insight
A KMS-held pepper would have made the 120,000-row dump above far less useful. Symptom: pepper checked into the repo beside the database URL. Rule: HMAC with a versioned KMS secret outside the database.
🎯 Key Takeaway
Salt per user automatically; pepper globally via KMS. Together they force per-account guessing and blunt database-only leaks.

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.

stacked_migration.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import hashlib

# Stacked migration pattern: legacy MD5 verified once, then upgraded.
# New hashes in production should be Argon2id; PBKDF2 stands in here
# so this demo runs on the standard library.
legacy_db = {}
new_db = {}

def legacy_hash(password: str) -> str:
    return hashlib.md5(password.encode()).hexdigest()

def new_hash(password: str, salt: bytes) -> str:
    return hashlib.pbkdf2_hmac('sha256', password.encode(), salt, 210000).hex()

def login(user: str, password: str) -> str:
    if user in new_db:
        salt_hex, stored = new_db[user]
        return 'ok' if new_hash(password, bytes.fromhex(salt_hex)) == stored else 'deny'
    if user in legacy_db and legacy_db[user] == legacy_hash(password):
        import os
        salt = os.urandom(16)
        new_db[user] = (salt.hex(), new_hash(password, salt))
        del legacy_db[user]
        return 'ok-upgraded'
    return 'deny'

legacy_db['ana'] = legacy_hash('s3cret!')
print(login('ana', 's3cret!'))
print(login('ana', 's3cret!'))
💡Stack, Don't Flag-Day
Verify the legacy hash once and rehash transparently at login. Dormant accounts get a reset deadline instead of blocking the migration.
📊 Production Insight
Stacked verification upgraded the breached app's active users within days of the incident. Symptom: migration blocked waiting for a mass-reset window. Rule: verify legacy once, rehash transparently, deadline the dormant tail.
🎯 Key Takeaway
Stack new verification over legacy, rehash on successful login, and deadline dormant accounts. The legacy count drains without downtime.

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.

📊 Production Insight
Breach screening plus spray alerts cut stuffing success to near zero within a week above. Symptom: policy ending at the hash function. Rule: screen new passwords, throttle logins, and push MFA for privileged accounts.
🎯 Key Takeaway
Pair slow hashes with breach screening, throttling, and MFA. Leaks still happen; reuse is what turns them into takeovers.
● Production incidentPOST-MORTEMseverity: high

An MD5 User Table Fell to 68 Percent Cracked in a Weekend

Symptom
On a Saturday morning, monitoring flagged the user table exported to an unfamiliar host, and by Monday a sample of 81,000 cracked passwords was circulating. Login abuse on customer email accounts spiked to 12,000 attempts per hour by Tuesday, with about 4,300 successful stuffing logins against reused passwords. The app itself showed no errors; the damage played out entirely on other sites and in password-cracking forums.
Assumption
The team assumed MD5 with a single site-wide salt was adequate because the hashes aren't plaintext and the database sits behind authentication. They also assumed SHA-256 elsewhere in the stack meant passwords were covered, without checking the actual users table. Everyone believed a database breach would be contained since passwords looked scrambled.
Root cause
The users table stored unsalted-equivalent MD5: one global salt shared across all 120,000 rows, computed in nanoseconds per guess. Attackers ran commodity GPUs at roughly 20 billion guesses per second, cracking 81,000 hashes (68 percent) over the weekend with dictionary plus rules. The cracked set powered credential-stuffing waves because users had reused passwords, turning one table leak into 4,300 external takeovers.
Fix
The team forced resets for all 120,000 accounts with sessions revoked, and rehashed into Argon2id with per-user salts plus a KMS-held pepper within 48 hours. Login was stacked during rollout: MD5 verified once, then transparently upgraded to Argon2id on success. Stuffing defenses added rate limiting, breach-password screening at registration, and alerts on password-spray patterns, cutting stuffing success to near zero within a week.
Key lesson
  • 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.
Production debug guideFive checks that find fast hashes and move every account to slow ones.5 entries
Symptom · 01
User table stores MD5, SHA-1, or single-round SHA-256 password hashes
→
Fix
Inventory hash formats: SELECT DISTINCT LEFT(pw_hash, 10) patterns and lengths (32 hex chars screams MD5, 64 screams SHA-256). Stand up stacked verification accepting legacy once, then rehash with Argon2id on success. Force resets for high-value accounts immediately rather than waiting for natural logins.
Symptom · 02
Unsure whether each hash carries a unique salt
→
Fix
Check for identical hashes on identical passwords in staging, and inspect whether the stored value embeds a per-user salt. If two users with password Test123! share a hash, salting is absent or global. Rehash with a scheme that salts automatically and verify duplicates disappear.
Symptom · 03
bcrypt cost factor is 4 or unknown, logins feel instant
→
Fix
Benchmark on production-grade hardware: time hashpw at cost 10, 11, and 12, targeting roughly 200 to 400 milliseconds per login. Raise the factor to the slowest value users tolerate, and record it in config with a review date. Rehash-on-login upgrades existing users to the new cost automatically.
Symptom · 04
Hash leak would expose everything since secrets sit beside hashes
→
Fix
Add a pepper held outside the database: HMAC the password with a KMS-stored key before Argon2id, or prepend a vault secret. Rotate the pepper on schedule with versioned identifiers. Confirm a database-only dump can't verify guesses without the pepper service.
Symptom · 05
No visibility into cracking or stuffing after a leak
→
Fix
Add breach-password screening at registration, rate limit and alert on spray patterns, and dashboard legacy-hash population weekly. Run a tabletop replay: assume the hash table leaks tonight and confirm resets, revocation, and comms ship within hours.
Password Storage Options Compared
Root CauseHow to ConfirmFixPrevention
MD5 or SHA-256 for passwords32 or 64 hex chars; cracks in hoursMigrate to Argon2id stackedBan fast hashes in review
Shared or missing saltsIdentical passwords share hashesPer-user automatic saltsDuplicate-hash test in CI
bcrypt cost too lowLogins take millisecondsTune to 200-400 ms; rehashAnnual cost benchmark
No pepper or stuffing defenseDB-only dump fully crackableKMS pepper plus screeningRotation and spray alerts
⚙ Quick Reference
4 commands from this guide
FileCommand / CodePurpose
bcrypt_tuned.pyCOST = 12bcrypt Work Factors
argon2id_hash.pyfrom argon2 import PasswordHasherArgon2id
pepper_hash.pyfrom argon2 import PasswordHasherSalts, Peppers, and KMS
stacked_migration.pylegacy_db = {}Migration Without Downtime

Key takeaways

1
MD5 and SHA-256 guess too fast for passwords; salts alone can't save cheap hashes.
2
bcrypt with tuned cost is a solid default; Argon2id adds memory-hardness as first choice.
3
Target a few hundred milliseconds per login and review cost factors yearly.
4
Salt per user automatically and add a KMS-held pepper for database-only leaks.
5
Migrate stacked
verify legacy once, rehash transparently, deadline dormant accounts.
6
Screen new passwords, throttle logins, and push MFA to stop reuse-driven takeovers.

Common mistakes to avoid

5 patterns
×

Storing passwords with MD5 or single-round SHA-256

Symptom
Commodity rigs crack the majority of rows over a weekend, fueling stuffing waves.
Fix
Stack-verify legacy once and rehash into Argon2id with per-user salts immediately.
×

Reusing one site-wide salt for every account

Symptom
Identical passwords share hashes and precomputation pays off across the table.
Fix
Use schemes that generate unique salts automatically; test that duplicates differ.
×

Leaving bcrypt cost at 4 for snappy logins

Symptom
Each guess costs microseconds, so cracking throughput stays enormous.
Fix
Benchmark to a few hundred milliseconds and rehash-on-login to the new cost.
×

Keeping the pepper in source control beside the code

Symptom
Every repo reader holds the secret, so database-only containment never existed.
Fix
Move the pepper to KMS with versioned rotation and HMAC application.
×

Skipping breach screening and spray detection

Symptom
Cracked and reused passwords walk straight back in through registration and login.
Fix
Screen new passwords against breach lists; throttle and alert on spray patterns.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
Why is MD5 wrong for passwords even with a salt?
Q02JUNIOR
Why does SHA-256 also fail for password storage?
Q03SENIOR
How do you pick a bcrypt cost factor?
Q04SENIOR
What do pepper and KMS add over Argon2id alone?
Q05SENIOR
How do you migrate 120,000 MD5 rows without downtime?
Q01 of 05JUNIOR

Why is MD5 wrong for passwords even with a salt?

ANSWER
It's nanoseconds fast with tiny state, so GPUs guess billions per second per account. Salt stops precomputation but not cheap guessing. Password storage must be slow and memory-hard, which MD5 can't do.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
Is salted SHA-256 with many rounds acceptable?
02
What Argon2id parameters should I start with?
03
Does bcrypt's 72-byte limit matter?
04
Should users all reset passwords during migration?
05
How do I check passwords against breach lists safely?
06
Can I just encrypt passwords instead of hashing?
N
Naren Founder & Principal Engineer

20+ years shipping production backend systems. Written from production experience, not tutorials.

Follow
✓ Verified
production tested
September 26, 2026
last updated
2,085
articles · all by Naren
🔥

That's Crypto. Mark it forged?

5 min read · try the examples if you haven't

←
Previous
TLS Handshake Failure: No Cipher Suites in Common
3 / 3 · Crypto
Next
OAuth 2.0 Redirect URI Mismatch Error
→