Every application that authenticates users makes a quiet bet: that the password database will never leak. It is a bet you will eventually lose. Breaches happen through SQL injection, misconfigured backups, insider access, and a hundred other paths. When that day comes, the only thing standing between your users and account takeover across every other site where they reused that password is how you stored it. Done well, a leaked password table is an inconvenience. Done badly, it is a catastrophe that unfolds for years. The encouraging part is that the correct approach is well-understood, freely documented, and available as a one-line library call in every major language.
Why a fast hash is the wrong tool
The instinct many engineers reach for is a cryptographic hash like SHA-256. It feels secure — it is a real cryptographic primitive, it is one-way, it produces a fixed-length digest. But speed is exactly the problem. SHA-256 and MD5 were designed to be fast: to checksum a file or verify a download as quickly as possible. That same speed means an attacker with commodity GPUs can try an enormous number of candidate passwords per second against a stolen hash. The property that makes these functions excellent for integrity checks makes them dangerous for passwords.
Password storage inverts the usual goal. You want the hash to be slow, and ideally memory-hard — expensive not just in CPU time but in RAM, which is the resource that is hardest for an attacker to parallelize cheaply across GPUs and custom hardware. A legitimate login server hashes one password per sign-in and does not care about a few hundred milliseconds. An attacker hashing billions of guesses cares enormously. Deliberately slow, memory-hungry hashing turns the economics against them.
Salt, and why it is not optional
Even a slow hash is not enough on its own, because attackers precompute. If two users share the password hunter2, an unsalted scheme stores the identical hash for both, and a precomputed lookup table (a rainbow table) reverses common passwords instantly.
A salt is a unique random value added to each password before hashing. Because every user's salt differs, identical passwords produce different hashes, precomputation is defeated, and an attacker is forced to attack each account individually. OWASP's guidance is that a unique salt must be added to each password — and the good news is that modern password-hashing algorithms generate and manage the salt internally and store it alongside the hash, so you do not have to handle it manually.
For defense in depth, OWASP also suggests considering a pepper: a secret value added to every password that is stored separately from the database — for example in a secrets manager or HSM — so that a database-only leak does not hand the attacker everything they need. A salt lives with the hash; a pepper deliberately does not.
The 2026 hierarchy of algorithms
OWASP's Password Storage Cheat Sheet gives a clear priority order. Use the first one available to you:
- Argon2id — the top recommendation. Minimum configuration: 19 MiB of memory, an iteration count of 2, and 1 degree of parallelism. OWASP lists several equivalent trade-offs, such as
m=12 MiB, t=3, p=1orm=7 MiB, t=5, p=1— more memory lets you use fewer passes and vice versa. - scrypt — if Argon2id is unavailable. Use a minimum CPU/memory cost parameter of 2^17, a block size of 8 (1024 bytes), and a parallelization parameter of 1.
- bcrypt — for legacy systems. Use a work factor of 10 or more. Note two real gotchas: bcrypt has a maximum input length of 72 bytes and silently ignores anything beyond it, and pre-hashing the password with a fast function to get around that limit can introduce null bytes into the hash input — so do it carefully, if at all.
- PBKDF2 — only when FIPS-140 compliance requires it. Use a work factor of 600,000 or more with HMAC-SHA-256.
The practical takeaway: unless a compliance regime forces your hand, reach for Argon2id, and fall back to scrypt or a well-configured bcrypt only when you must.
Getting the parameters right
Using the right algorithm with weak parameters defeats the purpose. Treat OWASP's numbers as a floor, not a ceiling. If your hardware can comfortably afford more memory and iterations without hurting login latency, use more — and revisit the settings over time, because attacker hardware only gets cheaper. The work factor is the dial you are expected to turn upward every few years.
Critically, never implement any of this yourself. Use a vetted library that handles salting, encoding, and parameter storage for you:
# Illustrative only — use a maintained library, never hand-rolled crypto
from argon2 import PasswordHasher
# 19 MiB = 19456 KiB, matching the OWASP minimum
ph = PasswordHasher(memory_cost=19456, time_cost=2, parallelism=1)
stored = ph.hash("correct horse battery staple") # salt generated internally
ph.verify(stored, user_supplied_password) # raises on mismatch
# Transparently re-hash if you later raise the parameters
if ph.check_needs_rehash(stored):
stored = ph.hash(user_supplied_password)The check_needs_rehash pattern is worth adopting from day one: it lets you strengthen parameters later and migrate users' hashes seamlessly on their next successful login, with no forced password reset.
Key Takeaways
- Never store plaintext, and never use a fast hash like MD5 or SHA-256 alone — their speed is the vulnerability.
- Password hashing should be deliberately slow and memory-hard to wreck the attacker's economics while barely affecting a single legitimate login.
- A unique per-password salt is mandatory; modern algorithms manage it for you. Consider a separately-stored pepper for defense in depth.
- Prefer Argon2id (min 19 MiB / t=2 / p=1), then scrypt, then bcrypt (work factor ≥10, mind the 72-byte limit), with PBKDF2 reserved for FIPS-140.
- Use a maintained library, tune parameters above the minimum, and raise them over time with a rehash-on-login migration path.
