Security
Password storage: why a slow, salted, memory-hard KDF is the only safe choice
The breach notification lands on a Friday. An attacker dumped your users table — emails and password hashes. The CTO asks one question: “How bad is it?” If those hashes are SHA-256, the honest answer is “every weak and medium password is already cracked.” A single RTX 4090 chews through about 22 billion SHA-256 guesses per second; against a leaked dictionary plus rules, the common passwords fall in minutes and the rest over a weekend. If the hashes were Argon2id with a per-user salt, the same answer is “they’ll get a handful of trivially weak ones and then give up — each guess costs them real RAM and time.” Same breach, two completely different Mondays. The difference was decided years earlier, in one line of signup code.
Fast hashes are the wrong tool
SHA-256, SHA-512, and MD5 were designed to be fast — they exist to fingerprint files and verify integrity at gigabytes per second. For passwords, fast is the bug, not the feature. The threat model is offline cracking: once an attacker has your hash column, they are no longer rate-limited by your login endpoint. They run guesses locally, as fast as their hardware allows, against the leaked hashes.
The numbers are brutal. On a single consumer GPU (RTX 4090), hashcat benchmarks land around 164 GH/s for MD5 and ~22 GH/s for SHA-256 — that is 22,000,000,000 guesses per second, per card. Rent eight in the cloud and you are at ~180 billion SHA-256/sec. Against the standard wordlists (rockyou plus mutation rules), this means every password that is a dictionary word, a name plus a year, or a common pattern is recovered effectively instantly. A general-purpose hash gives the attacker the one thing you must deny them: throughput.
The fix is to make each single guess expensive on purpose. A password KDF (key derivation function) is a hash deliberately engineered to be slow and resource-hungry, so that one verification at login is cheap (a few hundred milliseconds, once) but a billion offline guesses become economically impossible. You are not trying to be unbreakable; you are trying to make cracking cost more than the credentials are worth.
Salt: per-user, random, defeats precomputation
Before slowness, fix uniqueness. Two users with the password Summer2024! must not share a hash — if they do, an attacker who cracks one cracks both, and worse, can use a rainbow table: a giant precomputed map of hash → password built once and reused against every unsalted database forever.
A salt is a random value (≥16 bytes, from a CSRNG) generated per user and stored alongside the hash. You hash salt + password instead of password. Now identical passwords produce different hashes, rainbow tables are useless (the attacker would need a separate table per salt), and the attacker is forced to crack each account individually. The salt is not a secret — it lives in the same row as the hash, and that is fine; its job is uniqueness, not concealment. Modern KDFs generate and embed the salt for you, encoded right inside the output string, so you store one field, not two.
| Approach | Offline guess speed (1 GPU) | Verdict |
|---|---|---|
md5(password) | ~164 billion/sec | Catastrophic — also rainbow-table-able |
sha256(password) | ~22 billion/sec | Catastrophic — fast = wrong tool |
sha256(salt + password) | ~22 billion/sec | Salt blocks rainbow tables, but still far too fast |
bcrypt(password) cost 12 | ~tens of thousands/sec | Acceptable — slow + salted by design |
argon2id m=19 MiB, t=2 | ~thousands/sec, needs 19 MiB RAM each | Preferred — slow + salted + memory-hard |
The three accepted algorithms and OWASP’s numbers
OWASP’s Password Storage Cheat Sheet names exactly three modern choices. Use real, named parameters — not defaults you never checked.
- Argon2id (preferred). Won the 2015 Password Hashing Competition; standardized in RFC 9106. It is memory-hard: each guess must allocate a configured block of RAM, which is what cripples GPU and ASIC attacks (a GPU has thousands of cores but limited memory bandwidth, so it can’t run thousands of memory-hungry guesses in parallel). OWASP minimum: m=19456 (19 MiB), t=2, p=1. The
idvariant blends data-independent and data-dependent passes for both GPU and side-channel resistance — pickid, notiord. - scrypt. Also memory-hard, predates Argon2. OWASP parameters: N=2^17, r=8, p=1. A solid choice where Argon2 isn’t available.
- bcrypt (legacy, still acceptable). Based on Blowfish, slow and salted, no memory-hardness. Minimum cost factor 10 (each +1 doubles the work). Fine for existing systems; for new code prefer Argon2id.
PBKDF2 exists for FIPS-compliance environments (600,000+ iterations with HMAC-SHA-256), but it is not memory-hard and is the weakest of the four against GPUs. Reach for it only when a compliance regime forces it.
bcrypt’s 72-byte trap
bcrypt has a sharp edge that has caused real production auth bypasses: it silently ignores everything past the first 72 bytes of input. This comes from Blowfish’s key-schedule limit. So correct horse battery staple ...(73+ chars) and a different 73rd-character-onward string hash to the same value. In 2024 this contributed to incidents where overly long inputs collapsed into matching hashes, weakening authentication.
It gets worse if you try to “fix” bcrypt’s 72-byte limit by pre-hashing with a fast hash and then base64-encoding: a SHA-256 digest is 32 bytes but its hex/base64 form can be 44–64 bytes, and if you also prepend other data you can blow past 72 again — re-truncating the very entropy you tried to preserve. The correct pattern when you need long-password support on bcrypt is to pre-hash with SHA-256 and pass the raw bytes (or base64 that stays ≤72 bytes), or simpler: use Argon2id, which has no such limit. At minimum, validate input length and reject or pre-hash before bcrypt sees it.
Why this works
Why memory-hardness matters more than raw slowness: an attacker’s edge is parallelism. A GPU runs thousands of guesses at once, so a purely CPU-slow hash (like a high PBKDF2 iteration count) still gets divided across cores. Memory-hardness changes the economics — if each guess needs 19 MiB of fast RAM, a card with limited memory bandwidth can only run a few in parallel, not thousands. You attack the attacker’s hardware advantage directly, not just their clock speed.
Work factor must rise over time, plus a pepper
Your cost parameters are not “set once.” Hardware gets faster every year, so a cost that took 250 ms in 2020 is cheap today. Treat the work factor as a value you bump on a schedule — and the standard place to do it is at login: when a user authenticates successfully and you notice their stored hash uses outdated parameters, transparently re-hash their plaintext (which you have, for that one request) with the new cost and update the row. Over weeks, your whole table migrates with zero forced resets.
Two more defensive layers a senior adds. A pepper is a single secret value mixed into every hash (often via HMAC) but stored outside the database — in a KMS, env var, or HSM. It is defense-in-depth: if only the DB leaks (the common case) but the app secret doesn’t, the attacker has salted hashes with no pepper and cannot crack them at all. Finally, when verifying a login, use a constant-time comparison for any equality check you perform yourself, so the time taken doesn’t leak how many leading bytes matched — though note the standard verify functions (bcrypt.compare, Argon2’s verify) already do this for you. Don’t write storedHash === computedHash by hand.
You're building auth for a new service. Pick how to store passwords.
Why is SHA-256 the wrong choice for storing passwords, even with a per-user salt?
What is the role of a per-user salt, and is it secret?
Order the steps to store a new user's password safely:
- 1 Generate a random per-user salt from a CSRNG (the KDF usually does this for you)
- 2 Run a memory-hard KDF (Argon2id m=19 MiB, t=2, p=1) over salt + password
- 3 Store the encoded output string (algorithm + params + salt + hash) in one column
- 4 At each login, verify with the KDF's constant-time compare function
- 5 On a successful login with outdated params, transparently re-hash at the new work factor
- 01An engineer says 'we salt our SHA-256, so password storage is handled.' Explain why that's still a serious problem and what to change.
- 02What is the difference between a salt and a pepper, and where does each one live?
Password storage is decided by one principle: the attack you defend against is offline cracking, where the attacker has your hash column and runs guesses at full hardware speed — about 22 billion SHA-256 per second on a single GPU. That makes a fast general-purpose hash the wrong tool entirely; its speed is precisely what loses the database. The answer is a deliberately slow, memory-hard key derivation function. Salt every password with a per-user random value to defeat rainbow tables and force per-account cracking — the salt is not secret and lives beside the hash. Then pick a vetted KDF with named parameters: Argon2id (preferred, RFC 9106, OWASP m=19 MiB, t=2, p=1) for memory-hardness that cripples GPU parallelism, scrypt (N=2^17), or bcrypt (cost 10+, watching its 72-byte truncation trap). Raise the work factor as hardware improves, transparently re-hashing at login. Add a pepper kept outside the database for defense-in-depth, and always verify with the library’s constant-time compare rather than rolling your own equality check.