You do not choose a hash for the database; you choose it for the attacker who reads the database. Dedicated password hashes trade speed for resilience so a leak does not become a password factory.
Fast is the wrong goal
Speed is the enemy here. General-purpose hashes like SHA or MD5 run in nanoseconds and are intentionally fast, which is exactly what an offline attacker wants — they can churn through billions of guesses per second. Purpose-built password algorithms (bcrypt, scrypt, Argon2id) add a tunable cost factor plus per-user salt, so each guess costs real work (say 100ms-1s) and each account produces a different result even with a shared password. The slowness is the feature; it is what converts an instantly-downloadable hash file into a long, expensive grind. Choose an algorithm that is memory-hard where feasible, and pick a cost that stays high yet tolerable on the hardware that has to verify every login.
Design for the day the copy leaks
Design for the day the hash file leaks, because that day eventually comes. If the hashes were fast and unsalted, the attacker cracks the whole file in minutes — every weak password in the batch is gone at once. With slow, salted hashes, the same leak yields only a small fraction of the weakest passwords after real effort; the salt means identical plaintexts have different digests, so the attacker cannot amortize one guess across many accounts. Slowness plus salt and unique randomness per account is the resilience that survives the leak; there is no way back once the file is out, so the defense has to be in the stored form.
Agility when costs change
Keep the choice in one function you can change, and plan for cost to drift upward. When you raise the work factor or move to a beefier scheme, the stored hashes carry an older cost; the pattern is to rehash on the next successful login and update the record. That keeps every account’s cost current without requiring every user to reset a password, and it means your scheme can track hardware and algorithm advances over years. The permission model of ‘verify with latest, refresh on login, downgrade never’ should be explicit in the code, and the transition path (verified old hash -> new hash) must be a code path, not a manual migration.
When it breaks
A single fast digest is the classic failure. SHA-256 or MD5 verifies in microseconds, so after a dump the attacker replays billions of guesses per second per GPU; weak passwords fall in hours and the whole table falls together because no per-guess cost remains. Missing salt makes it worse: identical passwords share one digest, so a single guess breaks every account that chose it, prebuilt tables transfer across breaches, and equality itself leaks who shares a password. A global salt only blocks prebuilt tables while still letting one guess test the entire file at once.
A frozen cost factor is the quieter failure. Bcrypt cost 04 or PBKDF2 with a few thousand iterations may have been reasonable years ago, but hardware moved on while stored values did not. Comparison and availability break next. A plain equality check can exit on the first differing byte and expose an online timing oracle, so verification must use constant-time comparison. In the other direction, unbounded cost on the login path turns each bogus attempt into CPU you pay for; cap the maximum work, rate-limit logins, and watch for silent truncation such as the bcrypt 72-byte limit or encoding mismatches that let different inputs verify identically.
Check it quickly
Save the following as hash_cost_demo.py in a scratch directory and run python3 hash_cost_demo.py. It uses only the standard library (hashlib, hmac, os, time), makes no network calls, and writes nothing to disk. The script contrasts fast digests against PBKDF2-HMAC-SHA256 stretching, shows two random salts give different digests for one password, and checks verification with constant-time comparison.
"""Fast digest vs stretched password hash (stdlib only)."""
import hashlib
import hmac
import os
import time
def check(ok, message):
if not ok:
raise AssertionError(message)
def main():
password = b"correct-horse-123"
wrong = b"correct-horse-124"
# 1. Fast hashes are cheap: time thousands of single SHA-256 digests.
rounds = 5000
start = time.perf_counter()
for _ in range(rounds):
hashlib.sha256(password).digest()
fast_secs = time.perf_counter() - start
print(f"fast: {rounds}x SHA-256 in {fast_secs*1000:.2f} ms")
# 2. Stretching adds a tunable cost: one PBKDF2-HMAC-SHA256 derivation.
# Demo uses 100k iterations so it finishes instantly; production
# guidance asks for much more (see OWASP Password Storage).
salt1 = os.urandom(16)
salt2 = os.urandom(16)
check(salt1 != salt2, "salts must differ")
start = time.perf_counter()
stored = hashlib.pbkdf2_hmac("sha256", password, salt1, 100_000)
slow_secs = time.perf_counter() - start
print(f"slow: 1x PBKDF2-HMAC-SHA256(100k) in {slow_secs*1000:.2f} ms")
# 3. Random per-user salt: same password, different salts, different digests.
other = hashlib.pbkdf2_hmac("sha256", password, salt2, 100_000)
check(stored != other, "random salts must separate identical passwords")
print(f"salt separates: {stored.hex()[:16]}... != {other.hex()[:16]}...")
# 4. Negative control: a global salt keeps identical passwords identical.
global_salt = b"site-wide-salt-01!"
alice = hashlib.pbkdf2_hmac("sha256", password, global_salt, 100_000)
bob = hashlib.pbkdf2_hmac("sha256", password, global_salt, 100_000)
check(alice == bob, "global salt must link identical passwords")
print("global salt links identical passwords (do not do this)")
# 5. Verify by re-deriving with the stored salt, using constant-time compare.
again = hashlib.pbkdf2_hmac("sha256", password, salt1, 100_000)
check(hmac.compare_digest(stored, again), "same password+salt must verify")
bad = hashlib.pbkdf2_hmac("sha256", wrong, salt1, 100_000)
check(not hmac.compare_digest(stored, bad), "wrong password must not verify")
check(hmac.compare_digest(stored, stored), "compare_digest self-check")
check(not hmac.compare_digest(stored, other), "compare_digest mismatch-check")
print("verify: correct password passes, wrong password fails")
print(f"ratio: one stretched hash costs ~{(slow_secs / (fast_secs / rounds)):.0f}x one fast digest")
print("PASS: slow salted stretching beats a single fast digest")
if __name__ == "__main__":
main()
The output reports microseconds for thousands of fast hashes against milliseconds for one stretched hash, then confirms re-derivation with the same salt reproduces the stored value while a wrong password fails. The assertions passed with Python 3.12 on macOS; timings vary by machine. The demo uses reduced iterations so it finishes instantly and does not prove Argon2id tuning, GPU cost, or production rate limiting. Re-check your verifier with its documented cost parameter and a login flood budget before raising work factors.
Sources
- OWASP: password storage, slow, salted hash choices, checked 2027-06-08.
- OWASP: cryptographic storage, hashing vs encryption, checked 2027-06-08.
- OpenSSL: digest tool, how digests are computed, checked 2027-06-08.