Password Hashing: Why BCrypt, Scrypt, and Argon2 Exist
SHA-256 is a great hash function and a terrible way to store passwords — because it's fast, and fast is exactly what an attacker with your leaked database wants. Password hashing algorithms are the ones deliberately engineered to be slow, memory-hungry, and unfriendly to GPUs.
The Leak Already Happened
Assume the worst has occurred: someone has a full dump of your users table. SQL injection, a leaked backup, a compromised read replica — the how doesn't matter. They have every row, including whatever you stored in the password column.
Everything you do before that moment — TLS, WAFs, rate limits, least privilege — is about preventing the dump. Password hashing is about what the attacker can do once they already have it. It's the last control standing, and it's the one that decides whether your incident report says "credentials were exposed" or "passwords were hashed with Argon2id and remain infeasible to recover."
The whole field exists to answer one question: how many guesses per second can the attacker make?
Why a Normal Hash Function Fails
SHA-256 is not broken. It's an excellent cryptographic hash — collision-resistant, preimage-resistant, well-analyzed. It's also designed to be fast, because that's what you want when hashing a file or verifying a signature.
Speed is a virtue everywhere except here. An attacker holding your hashes runs an offline attack: no network, no rate limiting, no lockout. Just a GPU rig, a wordlist, and as many guesses per second as the hardware allows.
| Stored as | Guesses per second, one modern GPU | Relative attacker advantage |
|---|---|---|
| SHA-256 (salted) | ~20,000,000,000 | 1x — the baseline disaster |
| PBKDF2-HMAC-SHA256, 600k iterations | ~30,000 | ~700,000x harder |
| bcrypt, cost 12 | ~10,000 | ~2,000,000x harder |
| Argon2id, 19 MiB memory | ~2,000 | ~10,000,000x harder |
These are rough figures and they change with every hardware generation — the ratios are the point, not the absolute numbers. Against raw SHA-256, an eight-character password from a realistic keyspace falls in hours. Against Argon2id at recommended parameters, the same attack is roughly seven orders of magnitude slower, which turns hours into geological time.
A hash function deliberately engineered to be slow and resource-hungry, with a tunable cost parameter. Verification for one legitimate login costs a few hundred milliseconds — irrelevant to a user, ruinous to an attacker doing billions of guesses. Because hardware keeps getting faster, the cost is a knob you turn up over time rather than a fixed property.
Salt: Killing the Precomputation Attack
Before slowness, there's a more basic problem. If you hash passwords with no salt, identical passwords produce identical hashes. That gives an attacker two enormous gifts: they can see which of your users share a password, and they can attack every account at once with a precomputed table (a rainbow table) rather than one at a time.
A salt is a unique random value per password, stored alongside the hash and mixed in before hashing. It doesn't need to be secret. Its only job is to make every user's hash unique, so the attacker must attack each account independently and no precomputed table applies.
$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHR2YWx1ZQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG ^ algorithm ^ version ^ cost parameters ^ salt ^ the actual hash Everything needed to verify is in the string. The salt is right there in plaintext — that's fine, and by design.
Notice what else is in there: the algorithm and its cost parameters. That self-describing format is what lets you change your parameters later without invalidating existing hashes. Any decent library does this for you — you should never be generating salts or concatenating strings yourself.
✅ Pepper is the optional extra
A pepper is a secret value, identical for all users, mixed in and stored outside the database — in an environment variable, a KMS, or an HSM. The point is that a pure database leak isn't enough to start cracking: the attacker needs application-server access too. It's genuinely useful defence-in-depth, but it's a bonus on top of proper hashing, never a substitute. Note that rotating a pepper means rehashing everything, so plan for that before adding one.
The Three Cost Dimensions
Making a function slow sounds trivial — just loop more. That's what PBKDF2 does, and it's the weakest of the modern options, because pure iteration count is exactly what specialized hardware parallelizes best. A GPU has thousands of cores; an ASIC can have millions of tiny circuits each running your loop.
The counter-move is memory hardness. Iteration is cheap to parallelize; memory is not. Give each guess a hard requirement of 19 MiB of RAM and a GPU with 24 GB of memory can only run about a thousand guesses concurrently, no matter how many cores it has. You've converted the attacker's advantage from "cores" to "RAM," which is far more expensive to scale.
| Dimension | What it controls | What it defends against |
|---|---|---|
| Time / iterations | How many passes over the internal state | Raw CPU throughput |
| Memory | How much RAM a single hash must occupy | GPU and ASIC parallelism — the big one |
| Parallelism | How many threads one hash may use | Tuning cost without adding wall-clock latency |
This is the through-line of the whole family. bcrypt (1999) was slow and used a small amount of memory, which was enough to blunt the hardware of its era. scrypt (2009) made memory a first-class tunable parameter. Argon2 (2015) won the Password Hashing Competition by making all three dimensions independently tunable, with a variant specifically hardened against side-channel and time-memory trade-off attacks.
Choosing Between Them
| Algorithm | Memory-hard | Recommended settings | Use when |
|---|---|---|---|
| Argon2id | Yes — tunable | m=19 MiB, t=2, p=1 (OWASP minimum) | New systems. The default choice. |
| scrypt | Yes — tunable | N=2^17, r=8, p=1 | Argon2 unavailable; solid and widely deployed |
| bcrypt | Barely (4 KiB, fixed) | cost ≥ 10, ideally 12+ | Existing systems; mature library support everywhere |
| PBKDF2-HMAC-SHA256 | No | ≥ 600,000 iterations | FIPS compliance requires it. Otherwise, don't. |
| SHA-256 / MD5 / SHA-1 | No | — | Never. Not with a salt, not with a loop you wrote. |
⚠️ bcrypt silently truncates at 72 bytes
bcrypt only considers the first 72 bytes of input. A user with a long passphrase gets the tail of it ignored — and if you use bcrypt as a second stage over some other value, you can accidentally collapse distinct inputs to the same hash. Some implementations also stop at the first null byte. If you need to support arbitrary-length inputs, pre-hash with HMAC-SHA-256 and base64-encode the result before passing it to bcrypt, so the input is always a fixed, null-free 44 bytes. Or use Argon2id, which has no such limit.
Argon2id is the recommended default, and the id matters: Argon2d is faster but its memory access pattern depends on the password, leaking information through side channels; Argon2i is side-channel resistant but weaker against time-memory trade-offs. Argon2id is the hybrid, and it's what OWASP and RFC 9106 point you to.
The cost parameter is exponential, which is what makes it a usable knob. bcrypt's cost factor is a power of two — every increment doubles the work:
Going from cost 10 to cost 12 costs a legitimate user 300 extra milliseconds once per login. It costs an attacker a 4x increase across every one of billions of guesses. That asymmetry is the entire mechanism.
🔴 Tune parameters to your hardware, not to a blog post
The published minimums are floors, not targets. Benchmark on the machine that will actually run logins and raise the cost until a single verification takes roughly 250–500ms under your expected concurrency. Then write the number down and revisit it — hardware gets faster every year, and a cost factor chosen in 2019 is meaningfully weaker today. Remember this cost is per login attempt, so an authentication endpoint under load needs sizing accordingly; it is also, conveniently, a natural brake on online guessing.
The Login Path, End to End
Getting the algorithm right is most of the work. These are the details around it that leak information or lock you into your original parameters.
Look up the user — then hash regardless
If you skip hashing when the email doesn't exist, your response time answers "does this account exist?" for anyone who asks. Attackers enumerate accounts this way. Run a verification against a dummy hash for unknown users so both paths cost the same, and return an identical generic error either way.
Verify with the library's compare function
Never compare hash strings with ==. Use the library's verify function, which parses the stored parameters and compares in constant time. A naive comparison returns faster the earlier it finds a mismatched byte — a timing side channel.
Rehash opportunistically on success
This is the step everyone skips. At the moment of a successful login you have the plaintext password in memory — the only moment you ever will. If the stored hash uses parameters weaker than your current policy, recompute it now and update the row. Over a few months of normal logins, your entire active user base migrates to stronger parameters with zero user-visible disruption and no forced resets.
Rate limit the endpoint anyway
Slow hashing protects the leaked database. It does not protect the live login form, where an attacker can spray one common password across thousands of accounts and never trip a per-account lockout. Rate limit per IP and per account, and watch the aggregate failure rate across all accounts — credential stuffing looks normal one account at a time.
Are you bound by FIPS-140 or a similar compliance regime?
Key Takeaways
- Password hashing is your last line of defence, and the only one that still matters after the database has leaked. Its entire job is to reduce the attacker's guesses per second.
- General-purpose hashes fail because they're fast. SHA-256 with a salt and your own loop is not password hashing — it's a slower version of the same mistake.
- Salts are mandatory and public. They defeat rainbow tables and hide password reuse across accounts. Let the library generate and encode them.
- Memory hardness is the modern defence. Iteration count is what GPUs parallelize best; a per-guess RAM requirement is what they can't.
- Argon2id is the default, scrypt is a fine alternative, bcrypt is acceptable at cost 12+ (mind the 72-byte truncation), PBKDF2 only when compliance demands it.
- Benchmark on your own hardware and target roughly 250–500ms per verification, then revisit the number as hardware improves.
- Rehash on successful login to migrate parameters silently, hash even for unknown users to prevent enumeration, and rate limit the login endpoint — slow hashing does nothing for online attacks.
References
- OWASP Password Storage Cheat Sheet — current recommended algorithms and parameters, kept up to date
- RFC 9106 — Argon2 Memory-Hard Function — the specification, including the Argon2i/d/id distinction and parameter guidance
- Password Hashing Competition — the 2013–2015 process that selected Argon2, with the submissions and analyses
- Stronger Key Derivation via Sequential Memory-Hard Functions — Colin Percival — the scrypt paper, and the original argument for memory hardness