I review a lot of password hashing code, and the failure always has the same
shape. Someone pragmatic and otherwise security-aware hands me a login
system that stores sha256(password + salt) — or, worse, a bare MD5 — and asks
whether the argon2 vs bcrypt debate really matters “for a small app”. It does,
and not because of fashion. Fast hashes exist to be fast, and a login is the one
place where speed is the enemy: whoever steals your database gets to run the same
function billions of times, offline, with no rate limit. So: what password hashing
must resist, why salted memory-hard functions change the economics, and how to
do real password storage in 2026 without locking a single user out.
What password hashing must resist
The threat model is not “someone guesses the password on the login page”; it is
“someone walks off with a copy of your users table” — a leaked backup, a SQL
injection, a stolen replica. Once the attacker holds the digests, they guess
against their own hardware, offline, for as long as they like.
- Offline brute force. No rate limit, no lockout, no CAPTCHA. A dictionary, then mangling rules, then mask patterns, each candidate checked against every account at once. Your only lever is how expensive one guess is.
- GPU and ASIC attacks. A modern GPU runs SHA-256 at tens of billions of hashes per second; a purpose-built ASIC far more. A scheme is safe only if its cost per guess survives that parallelism.
- Rainbow tables. Precomputed
hash → plaintexttables let an attacker look a digest up instead of computing it — because the same password yields the same digest everywhere.
Speed makes all three cheap, so the entire job of a password hash is to be slow — and memory-expensive, a different and stronger kind of slow.
Why fast hashes are wrong (md5, sha-1, sha-256 is a crime)
MD5, SHA-1, and SHA-256 were designed for integrity, not secrecy, so they are deliberately cheap: one pass, a few dozen ALU ops per block, tiny state. Perfect for hashing a 4 GB file; fatal for a password. Rough rates on one high-end GPU:
- MD5: ~1011 guesses/second.
- SHA-1 / SHA-256: ~1010/s, more with tuning.
- bcrypt (cost 12): ~104–105/s.
- Argon2id (64 MiB, t=3): ~102–103/s, and memory-bound.
Six to eight orders of magnitude — “cracked before lunch” versus “not in your lifetime”. An eight-character password spans roughly 1012–1015 candidates; a fast hash sweeps it in hours, a memory-hard function in centuries.
“But I add a salt”
A salt is necessary and not sufficient, and the two get confused.
sha256(salt + password) is salted, unique per user, and still a crime: the GPU
still runs the function at 1010/s per candidate. The salt fixes rainbow
tables and identical digests; it does nothing about raw speed.
Salt and memory-hard functions: the two ideas that work
A salt is a random value (16 bytes is plenty) generated when the password is set and stored in cleartext beside the digest. It is not a secret. It makes precomputation useless, makes identical passwords produce different digests, and forces the attacker to attack one account at a time, forever. Use a unique random salt per user from a CSPRNG — never global, never the username.
A memory-hard function forces every guess to allocate and touch a large working set — tens of megabytes — so throughput is bounded by memory bandwidth, not by ALU count. That is why Argon2id and scrypt stay slow on the hardware that made SHA-256 trivially parallel. Memory-hard, salted, and with a tunable time cost is the baseline; anything missing one of the three is a rounding error in 2026.
argon2 vs bcrypt vs scrypt (and pbkdf2 for FIPS)
Four functions are worth knowing: the modern default, a fine legacy fallback, a reasonable alternative, and the compliance one.
Argon2id — the default I recommend
Argon2 won the Password Hashing Competition in 2015 and is standardized in RFC 9106.
Of its three variants — argon2d (data-dependent, leakier on shared hardware),
argon2i (data-independent, side-channel resistant), and argon2id — the
hybrid is the right default for passwords: it resists GPU attacks while staying
hardened against side channels. Its parameters are explicit: memory m in KiB,
time cost t, parallelism p. OWASP’s floor is
m=19 MiB, t=2, p=1; RFC 9106 suggests m=64 MiB, t=3, p=4 and a high-memory
m=2 GiB, t=1, p=4.
bcrypt — still fine, and everywhere
bcrypt (from 1999) is not going anywhere: a real work factor, implemented in every language and framework, and a cost of 12 is still a serious wall for a GPU. Two caveats: it is not memory-hard, so FPGAs and ASICs get more of an edge than they do against Argon2id, and it silently truncates the password at 72 bytes. Inherit a bcrypt database? Keep it, raise the cost factor, migrate lazily. Choosing today? Choose Argon2id.
scrypt — memory-hard, and a fine older option
scrypt (2009) was memory-hard before the term was fashionable: parameters N
(cost), r (block size), and p (parallelism), with a footprint of roughly
128 · N · r bytes; N=2^17, r=8, p=1 is about 128 MiB. Solid, but Argon2id offers
the same protection with simpler parameters and better side-channel behaviour.
pbkdf2 — when FIPS matters
PBKDF2 is old, ubiquitous, FIPS-approved, and the weakest of the four: CPU-iterated only, not memory-hard, so GPUs love it. OWASP currently suggests around 600,000 iterations of PBKDF2-HMAC-SHA256. Use it only when a FIPS 140-3 validation or a regulated client requires it; otherwise reach for Argon2id first.
| Function | Memory-hard | Typical 2026 params | Use when |
|---|---|---|---|
| Argon2id | yes | m=64 MiB, t=3, p=4 | default for new code |
| bcrypt | no | cost 12 | you inherited it |
| scrypt | yes | N=2^17, r=8, p=1 | ecosystem already uses it |
| PBKDF2 | no | 600k iterations SHA-256 | FIPS / compliance |
Picking parameters: memory, time, and how to choose them
The rule is the same for all of them: benchmark on the machine that will do the work, raise the cost until one hash takes roughly 250–500 ms at your expected login concurrency, and stop before logins become a denial-of-service vector. Don’t copy a blog’s numbers onto a different-sized box.
Prefer memory over iterations. Memory shrinks how many guesses fit in the
attacker’s fast cache and raises the ASIC bar; extra iterations mostly just burn
your own CPU. Reserve a budget per concurrent login — 64 MiB is a fine start — pick
m so m × concurrent_logins fits, then tune t to hit the latency target.
# Benchmark on the box that will run the login, not on your laptop.
# argon2 -m <log2 KiB> -t <passes> -p <lanes>; 2^16 KiB = 64 MiB
for m in 15 16 17; do
for t in 2 3 4; do
/usr/bin/time -f "m=2^${m} t=${t} p=4 -> %e s" \
argon2 -m "$m" -t "$t" -p 4 -l 32 -e \
-s "$(head -c 16 /dev/urandom | base64)" <<< "benchmark" >/dev/null
done
done
Here is a concrete Argon2id hash in Python, with the parameters that benchmark landed on for my login server:
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
# 64 MiB, 3 passes, 4 lanes — tuned for ~250 ms per hash on the login host.
ph = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=4,
hash_len=32, salt_len=16)
digest = ph.hash("correct horse battery staple")
print(digest)
# $argon2id$v=19$m=65536,t=3,p=4$<base64 salt>$<base64 digest>
# ^scheme ^ver ^parameters
# Everything a verifier needs lives inside the string — scheme and params included.
Peppering: a secret your database never holds
A pepper is a secret key mixed into the password before hashing and stored somewhere other than the password database — an application config value, a systemd credential, a KMS. If the database leaks but the app server does not, the attacker cannot verify guesses at all. Unlike a salt it is secret and shared per deployment, so treat it as a key, not a field.
The simplest correct pattern is to HMAC first, then hash:
import hmac, hashlib
from argon2 import PasswordHasher
ph = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=4)
def hash_with_pepper(password: str, pepper: bytes) -> str:
# The pepper never reaches the database; argon2id still does the hard work.
pre = hmac.new(pepper, password.encode(), hashlib.sha256).digest()
return ph.hash(pre)
Two caveats: a pepper is only as good as its storage — keep it in a credential store, not the same repo as the schema, which is the discipline in secrets management — and rotating it means re-hashing every password, so version it and treat a rotation as a migration, not a config change.
Migrating to Argon2id: rehash on login without locking users out
You do not need a flag day. Argon2id digests are self-describing — scheme, version, and parameters all live in the stored string — so you can upgrade users one successful login at a time.
The technique is rehash on login:
- Read the scheme from the stored digest’s prefix.
- Verify the submitted password with that scheme.
- On success, if the scheme or parameters are below target, compute a fresh Argon2id digest and overwrite the row.
- Return success — the user notices nothing.
Accounts that never log in keep their old digest, which is fine: an unused credential is not the one leaking. Just make sure account recovery that bypasses verification writes an Argon2id digest too.
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
TARGET = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=4)
def check_and_upgrade(user, password, db):
stored = user.password_hash
if stored.startswith("$argon2"):
try:
TARGET.verify(stored, password)
except VerifyMismatchError:
return False
# Weak Argon2 params (e.g. m=19456,t=2) are upgraded transparently.
if TARGET.check_needs_rehash(stored):
db.set_password_hash(user.id, TARGET.hash(password))
return True
# Legacy bcrypt / pbkdf2 / sha256: verify with the old code, then upgrade.
if Legacy.verify(stored, password):
db.set_password_hash(user.id, TARGET.hash(password))
return True
return False
If your old scheme is a fast hash with no salt, treat those accounts as compromised rather than migrated — force a reset on next login instead of silently re-hashing a credential the attacker likely already has.
The operational side: rate limiting and breach checks
Hashing protects the snapshot; it does nothing about live guessing at your login form. Those are separate controls, and you need both.
- Rate limiting and lockout. Per-account and per-IP limits, exponential backoff, a short lockout after repeated failures, and MFA on anything sensitive — the same defense-in-depth posture as hardening a public server.
- Breach checks at signup and login. Reject passwords already known breached. The Have I Been Pwned range API checks without sending the password, using k-anonymity: send the first five hex characters of the SHA-1 and match the rest locally.
import hashlib, urllib.request
def breach_count(password: str) -> int:
sha1 = hashlib.sha1(password.encode()).hexdigest().upper()
prefix, suffix = sha1[:5], sha1[5:]
# Only a 5-char prefix leaves this process; the password itself never does.
body = urllib.request.urlopen(
f"https://api.pwnedpasswords.com/range/{prefix}", timeout=3
).read().decode()
for line in body.splitlines():
digest, count = line.split(":")
if digest == suffix:
return int(count)
return 0
Password storage in 2026: what I actually do
- Reach for Argon2id for anything new. 64 MiB, t=3, p=4 as a starting point, tuned on your hardware to ~250–500 ms. Salt every password, always.
- Never choose md5, sha-1, or bare sha-256 for password storage. Fast hashes are a category error, and a salt does not rescue them.
- Rehash on login. Self-describing digests turn migration into a background process, not a maintenance window. Never lock users out to change algorithms.
- Add a pepper if you can store it separately, and never verify a password without rate limiting and a breach check behind it.
- Re-check your parameters yearly. A setting safe in 2023 is cheaper in 2026; re-run the benchmark, raise the cost, and let rehash on login carry it out.