Password Hashing in 2026
Use Argon2id, store the full parameters with every hash, and upgrade hashes silently at sign-in. If compliance requires FIPS 140 validated cryptography, use PBKDF2-HMAC-SHA256 with at least 600,000 iterations instead.
Password hashing has one job: make each guess expensive for an attacker who has stolen your database, while keeping each sign-in cheap enough for your servers. Fast hashes such as MD5, SHA-1, and plain SHA-256 fail that job no matter how many times you salt them.
Recommended parameters
These are the OWASP Password Storage Cheat Sheet recommendations. Within each algorithm, the options are equivalent in strength; they trade memory against CPU. Benchmark on your own hardware before you choose. (OWASP)
| Algorithm | When to use | Minimum parameters |
|---|---|---|
| Argon2id | Default for new systems | m=19 MiB, t=2, p=1 (or m=46 MiB, t=1, p=1; or m=12 MiB, t=3, p=1) |
| scrypt | Argon2id unavailable | N=2^17 (128 MiB), r=8, p=1 (or N=2^16, r=8, p=2) |
| bcrypt | Legacy systems only | Cost 10 minimum, higher if your servers allow; 72-byte input limit |
| PBKDF2-HMAC-SHA256 | FIPS 140 required | 600,000 iterations |
| PBKDF2-HMAC-SHA512 | FIPS 140 required | 220,000 iterations |
Step by step: storing a password
- Receive the password over TLS. Never log request bodies on auth endpoints.
- Normalize Unicode to NFC so the same password typed on different devices produces the same bytes.
- Check length (Chapter 5) and screen it against breached-password data.
- Generate a random salt of at least 16 bytes from a cryptographically secure generator. Most libraries do this for you.
- Hash with Argon2id at your chosen parameters.
- Optionally apply a pepper (below).
- Store the result as a PHC string, which records algorithm, version, parameters, salt, and hash together:
$argon2id$v=19$m=19456,t=2,p=1$<salt-base64>$<hash-base64>
Step by step: verifying and upgrading
def verify_and_upgrade(user, password):
stored = user.password_hash
if not hasher.verify(stored, password): # constant-time compare inside
return False
if hasher.needs_rehash(stored): # old algorithm or weaker params
user.password_hash = hasher.hash(password)
save(user)
return True
That needs_rehash check is how you raise parameters every year or two without a migration project. Users who sign in get upgraded automatically. Users who never sign in keep weak hashes, so set a policy for them: expire the password and require a reset after a defined period of inactivity.
Peppers
A pepper is a secret key shared across all hashes and stored outside the database, ideally in a KMS or HSM. The cleanest design is a post-hash pepper: stored = HMAC-SHA256(pepper, argon2id(password)), or encrypting the hash with a KMS key. A database dump alone is then useless for offline cracking. (OWASP)
Two cautions. A pepper adds defense in depth; it never replaces a slow hash. And if a pepper leaks, you cannot re-pepper without the plaintext, so rotation means versioned peppers plus rehash on login. Store a pepper version ID next to each hash from day one.
bcrypt traps
- 72-byte limit. Most implementations silently ignore bytes after 72. Enforce a 72-byte maximum or pre-hash correctly.
- Unsafe pre-hashing.
bcrypt(sha512(password))exposes you to "password shucking" and null-byte truncation. If you must pre-hash, usebcrypt(base64(HMAC-SHA-384(password, pepper)))with the pepper outside the database. (OWASP)
Migrating legacy hashes
If you inherit MD5, SHA-1, or unsalted hashes, wrap them now rather than waiting for users to sign in: argon2id(md5(password)), marked with a legacy flag. At the next sign-in, verify through the wrapper, then replace it with a direct Argon2id hash. This closes the exposure on day one instead of over years.
Capacity planning
Argon2id at 19 MiB uses 19 MiB of RAM per concurrent hash. Size verification capacity for peak sign-in rate times hash duration, plus headroom for credential-stuffing spikes. Run hashing in a separate worker pool so a login flood cannot starve the rest of the service (Chapter 15).
Common mistakes
- Using a fast hash with a salt and calling it secure.
- Hard-coding parameters with no record of them in the stored hash.
- Logging passwords in request logs, error trackers, or analytics.
- Comparing hashes with
==instead of a constant-time comparison.
Key takeaways
- Use Argon2id (m=19 MiB, t=2, p=1 minimum); PBKDF2-HMAC-SHA256 at 600,000 iterations when FIPS 140 is required.
- Store a PHC string so every hash records its own algorithm and parameters.
- Rehash on login to raise parameters without a migration project.
- Wrap legacy MD5 or SHA-1 hashes on day one instead of waiting for sign-ins.