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 to increase the cost of offline guessing after a leak.
Fast is the wrong goal
Speed is the enemy here. General-purpose hashes such as SHA-256 are intentionally fast, allowing offline attackers to test guesses efficiently; actual rates depend on algorithm and hardware. Purpose-built password algorithms (bcrypt, scrypt, Argon2id) add a tunable cost factor plus per-user salt, so guesses require deliberate work and independently salted accounts have different stored values even with a shared password. The added per-guess work is the feature; its benefit still depends on the selected parameters and password strength. 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
Treat database disclosure as a threat to plan for, not an event with a predictable cracking deadline. Weak passwords may still fall quickly under a strong scheme; strong passwords are not automatically recovered merely because a fast hash was used. Unique random salts prevent reuse of precomputed tables and require separate per-salt work. They are public inputs stored with the verifier, not encryption keys or additional password entropy. Hashing slows guesses; it does not make a breached database safe or replace incident response.
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 updates returning accounts without requiring every user to reset a password immediately, and it means your scheme can track hardware and algorithm advances over years. Verify with the algorithm, salt, and parameters stored in the existing record. Only after successful verification should a policy check trigger rehashing with current parameters. Preserve that distinction: verifying an old hash with new parameters would reject a legitimate password. Accounts that never log in still need an explicit migration or reset plan.
When it breaks
A single fast digest makes each offline guess inexpensive; the recovered fraction still depends on password strength and the attacker’s resources. 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. Use the password library’s verifier, or a suitable timing-resistant comparison for derived values; ordinary equality is not a cryptographic timing guarantee. 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 a reduced 100k iterations for a short exercise; 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: per-salt derivation, global-salt control, and password verification checks")
if __name__ == "__main__":
main()
The output reports milliseconds for both the fast-hash batch and one stretched derivation, then confirms re-derivation with the same salt reproduces the stored value while a wrong password fails. The earlier macOS run is historical; both exact examples passed on Python 3.12.14 in this review. Timings vary by machine. The demo uses reduced iterations 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, algorithm selection, salts and work-factor upgrades, checked 2026-10-15.
- Python hashlib, PBKDF2 parameters and key derivation, checked 2026-10-15.
- Python hmac, compare_digest behavior and timing caveats, checked 2026-10-15.