Why password hashing is deliberately slow
SHA-256 is fast and that's exactly why you must not use it for passwords. Password storage is the rare corner of computing where being slow — and greedy with memory — is the feature.
On this page
The picture version
Six pictures for a reader who has never thought about what a site stores when
it stores a password. Follow one row out of the leaked dump: your account,
your password mountainlion7.
1 · The problem
The guessing moved onto their hardware.
2 · The first fix
A salt kills the lookup table, not the guessing.
3 · Make one guess expensive
You pay 100 ms once. They pay it ten thousand times at once.
4 · The dial they can’t route around
Charge every parallel guess 64 MB of RAM.
5 · What it still can’t buy
Pricing a guess does nothing about how few guesses there are.
6 · Keep this card
Three ingredients, three different jobs.
Why it exists
You’ve gotten the email. “We recently became aware of unauthorized access to our systems. User passwords were stored in hashed form.” You change that password, and the three other sites where you reused it, and you move on. But that phrase — stored in hashed form — is carrying an enormous amount of weight, and whether it means anything at all comes down to a design decision almost nobody outside security hears about: the engineers made that hash slow on purpose.
Hold onto one concrete thing from that dump — a single row: your account,
your password mountainlion7, and whatever the site stored next to it. That
row is the running example for the rest of this post.
Most of computing is a long argument with physics about going faster. Caches, branch prediction, vectorization, GPUs — entire careers spent shaving nanoseconds. Then you wander into the password-storage corner of a codebase and find engineers carefully tuning an algorithm to be slower, with knobs labeled “memory cost” and “iterations” that they keep turning up every couple of years.
The reason is the threat model. Once the attacker has your row, they are no
longer knocking on your login endpoint where your rate limiter lives. They
have the stored value on their own hardware and can guess offline as fast as
their GPUs allow. A modern consumer GPU computes billions of generic hashes
per second for fast functions like MD5 and SHA-256. If the site stored
sha256("mountainlion7"), the attacker runs the entire
common-password corpus
against every user in the dump in the time it takes to brew coffee, and any
password built from a recognizable pattern — a word plus a digit, exactly
like mountainlion7 — falls with it.
Password hashing exists to break that economy. The goal is not to make guessing impossible — human-chosen passwords are too low-entropy for that — but to make each guess expensive enough that the attacker runs out of money or patience before reaching your row.
Why it matters now
Two things keep this sharp.
First, leaks happen. Database dumps end up on forums, paste sites, and Tor markets with depressing regularity. The question for any service handling credentials is not “what if our password column leaks” but “when it does, how much damage can the attacker do with it?”
Second, the hardware curve keeps moving. The same GPU boom that powers LLM training also powers password crackers. A cost setting that was “expensive enough” in 2015 is cheap now. ASIC and FPGA crackers are worse still against anything whose cost is just CPU-bound arithmetic. That hardware shift is what pushed the modern designs (Argon2, scrypt) toward being memory-hard rather than merely slow.
If you’re building auth today and reach for sha256 or md5, you are not
“hashing a password.” You are publishing it to whoever eventually steals
your DB.
The short answer
password hash = slow + memory-hungry + per-user salted KDF
Picture to keep: a turnstile that also makes you carry a 64 MB crate through it — one commuter barely notices the delay; an army trying to stream through in parallel needs a warehouse full of crates.
A password hash is a KDF tuned so that one verification is barely noticeable at your login endpoint (tens to hundreds of milliseconds) but a billion guesses cost a fortune. Regular cryptographic hashes (SHA-256, BLAKE3) are tuned for the opposite goal — be as fast as possible while staying collision-resistant. Same word, different jobs.
How it works
The design is easiest to reconstruct if you start with the obvious thing and
let each failure push you to the next fix. Throughout: one row, your account,
mountainlion7.
Naive attempt: store sha256(password). It’s one-way, so the row doesn’t
literally contain your password. Feels responsible.
Why it breaks: every site that does this produces the same value for
mountainlion7. An attacker computes one giant table mapping
hash → password once and reuses it against every leaked database forever —
the classic rainbow table.
Your row isn’t cracked, it’s looked up.
Fix 1: a salt. Store a per-user random value alongside the hash:
stored = (salt, hash(salt || password))
Now your row lives in its own namespace. The attacker’s precomputed table is worthless, because it would have to have been built for your salt. Salts don’t need to be secret — only unique. Sixteen random bytes is fine.
Why it still breaks: precomputation is dead, but live guessing isn’t. The
attacker takes your salt, and at billions of hashes per second walks the
common-password corpus against it. mountainlion7 is in that corpus. The
salt bought you the cost of one pass instead of a lookup — and one pass is
seconds.
Fix 2: a work factor. Make a single hash computation deliberately
expensive. In bcrypt that’s the cost parameter (each +1 doubles the work).
In Argon2 it’s a triple: time cost (iterations), memory cost, and
parallelism. Turn the dial until one verification takes ~100 ms, and the
attacker’s billion-guess sweep becomes a multi-year project.
Why it still breaks: “expensive” measured in arithmetic is exactly what a GPU is for. PBKDF2 and, to a lesser degree, bcrypt mostly pile on CPU iterations, and a card with thousands of cores runs thousands of those iterations in parallel. The defender pays 100 ms once per login; the attacker pays 100 ms divided by ten thousand.
Fix 3: memory-hardness. This is what separates Argon2 and scrypt from the older designs. A function is memory-hard when computing it requires holding a large working set in RAM — the algorithm fills a big array and then reads it back in an order it can’t predict ahead of time, so an attacker who tries to use less memory has to recompute so much that the trade stops paying. (It’s a penalty on the time-memory trade, not a prohibition.) GPUs have enormous arithmetic throughput but comparatively limited memory per core, and ASICs that bake in dedicated RAM per parallel guess get expensive fast. Ask Argon2 for 64 MB per hash and the attacker’s 10,000 parallel guesses now need 640 GB of RAM. That is the whole point.
Why it still breaks — or rather, where it stops: you cannot turn the dial
arbitrarily far, because every legitimate login pays the same cost. One
second per attempt would harden the hash and also hand an attacker a way to
exhaust your auth servers by hammering /login with junk.
Fix 4: calibrate, then re-calibrate. Pick the largest cost where verifying one password at login is still acceptable — usually somewhere in the 50–500 ms range — and raise it every few years as hardware moves. A login flow with a modern KDF ends up looking like:
- User submits password.
- Server fetches
(salt, params, stored_hash)for that row. - Server computes
Argon2id(password, salt, params)— ~100 ms, ~64 MB. - Constant-time compare against
stored_hash. - If
paramsare below current policy, transparently rehash and update.
Step 5 is how you migrate forward: on a successful login you briefly hold the plaintext, which is the only moment you can upgrade that row to stronger parameters.
Show the seams
- Pepper is a real but awkward extra layer. A pepper is a secret value mixed into the hash input and stored outside the database (in an HSM, env var, or KMS). It helps when the DB leaks but the secret store doesn’t — a pure DB dump becomes useless. It hurts when key rotation gets messy. It’s an extra layer rather than a baseline, and skipping it is fine if your salts and KDF are right.
bcrypthas a 72-byte input limit. Long passphrases get silently truncated past that point. The usual dodge is to pre-hash with SHA-256 first — but feed bcrypt the raw digest bytes and a\0byte inside them truncates the input again, which is why implementations base64-encode the digest before handing it over. Even done correctly, the construction now has an unsalted fast hash of the password sitting in the middle of it, so your row’s security is entangled with anywhere else that samesha256(password)value might exist. Argon2id avoids the whole genre.- Honest gap: which KDF is “best” depends on what’s available in your language ecosystem and how much you trust its implementation. The Password Hashing Competition (2013–2015) picked Argon2 as the winner, and Argon2id is the broadly recommended default for new systems. What nobody can tell you is the split between Argon2id, bcrypt, and scrypt across deployed systems: nobody surveys password-storage internals, because they’re the one thing a service has every reason not to publish.
- None of this saves a weak password — and
mountainlion7is a weak password. Password hashing prices each guess; it does nothing about how few guesses there are. Run the arithmetic on your own row with the numbers from Fix 3: a common-word list plus an appended digit or two is on the order of a hundred million candidates, and the attacker’s 10,000-way parallel rig at 100 ms a guess works through a hundred million of them in about a quarter of an hour.password123falls in the first second. The hashing bought real time against a high-entropy password and almost none against a recognizable pattern — which is why rate limiting, breach-corpus checks, and MFA do the work hashing structurally can’t.
You started with password hash = slow + memory-hungry + per-user salted KDF.
Which of those three is doing which job? — the salt kills precomputation,
the slowness prices one guess, and the memory is the part a GPU farm
can’t cheaply route around. Drop the third and a well-funded attacker just
buys more cores.
Check yourself
Before you go — a team migrating off sha256(password) can’t ask users for
their plaintext, so they wrap the old values: stored = Argon2id(sha256(password), salt, params),
computed over the existing column. Does your row get the security of Argon2id?
Answer
Against the new column, yes on the part that matters: an attacker who
steals it still has to run Argon2id once per candidate, so cracking
mountainlion7 costs Argon2id-per-guess. This “wrap the old hash” migration
is a standard trick precisely because it buys that without the plaintext.
But it isn’t simply “now we have Argon2id.” The construction has an unsalted,
fast sha256 sitting inside it, and the old dump — the one that leaked —
contains exactly those sha256 values. Anyone holding it can still crack
them at pre-Argon2 speed, and a cracked password works against the new column
too. Wrapping protects the column going forward; it can’t retroactively
protect bytes that already escaped. So the wrap is the stopgap, and Fix 4’s
step 5 is the actual fix: rehash from the real plaintext at each user’s next
successful login, then drop the wrapper.
And: your login endpoint is at 250 ms and someone proposes 2 seconds for “more security.” What breaks?
Answer
Your availability, before anything else. Each verification now holds ~64 MB
and a couple of seconds of compute, so a modest flood of junk /login
requests can exhaust CPU and RAM — you’ve turned your own defense into a
denial-of-service amplifier. (Exactly how badly depends on your server
architecture, but the direction doesn’t.)
And look at what you bought: 250 ms → 2 s is roughly 8×, about three bits of extra work for the attacker, which they can largely buy back with more hardware. The general shape is that your cost is bounded by your online capacity budget while the attacker’s cost rises only in proportion — which is why the heuristic is “the most you can afford at peak login rate” rather than “as much as possible,” and why raising the memory parameter is usually a better trade than raising time alone: memory is the axis where renting more cores doesn’t straightforwardly help.
Famous related terms
- bcrypt —
bcrypt = Blowfish key schedule + cost parameter— the workhorse from 1999; still acceptable today, with caveats about input length. - scrypt —
scrypt ≈ PBKDF2 + a big random-access memory array— the first widely used memory-hard design; Colin Percival, 2009. - Argon2id —
Argon2id = Argon2i (side-channel resistant) + Argon2d (GPU-resistant) hybrid— Password Hashing Competition winner; recommended default for new systems. - PBKDF2 —
PBKDF2 = HMAC + many iterations— the old standard (PKCS #5, originally RFC 2898, currently RFC 8018), CPU-bound only; fine where FIPS validation forces your hand, weak against GPUs at any reasonable iteration count. - HMAC —
HMAC ≈ keyed hash for message integrity— not a password hash; listed because people reach for it by mistake. - Rainbow table —
rainbow table = precomputed (hash → password) lookup— the attack that Fix 1 exists to kill.
Going deeper
- RFC 9106 — the Argon2 spec, for what the memory-cost and parallelism parameters actually control.
- OWASP’s Password Storage Cheat Sheet — for the question “what numbers should I put in the config today,” kept current as hardware moves.
- Colin Percival’s scrypt paper — for the question “why does forcing an attacker to buy RAM work better than forcing them to spend cycles,” argued in dollars-per-cracked-password.