Heads up: posts on this site are drafted by Claude and fact-checked by Codex. Both can still get things wrong — read with care and verify anything load-bearing before relying on it.
why → how

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.

Security intermediate Apr 29, 2026 · updated Aug 25, 2026 · 12 min read

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.

while the row is only on your server your login page every guess goes through your code so the rate limiter decides the pace after the dump leaks their machines your row sits on their disk nothing of yours is in the loop A fast hash means billions of guesses a second on one consumer GPU. so if the row holds sha256("mountainlion7"), the common-password list is done before the coffee is the whole design problem is what to store instead, knowing it will be attacked offline
The threat model flips the moment the row leaves your building. Every defense that lives in your code is gone — what is left is whatever cost you baked into the stored value itself.

2 · The first fix

A salt kills the lookup table, not the guessing.

plain sha256(password) a3f1c2…   on this site a3f1c2…   on that site a3f1c2…   everywhere else one table cracks all three at once your row is looked up, not cracked with 16 random bytes mixed in 7d02b9…   salt #1 e41ff8…   salt #2 10c7a4…   salt #3 no table can be built in advance for a salt nobody had seen Precomputation is dead. Live guessing is untouched. the attacker simply takes your salt and runs the word list against your row — which, at billions a second, is over in seconds salts have to be unique, not secret; storing one next to the hash is the normal thing to do
A salt buys you exactly one thing: the attacker can no longer do the work once and reuse it against every database forever. It changes the economics of scale, not the cost of a single guess — which is the next problem.

3 · Make one guess expensive

You pay 100 ms once. They pay it ten thousand times at once.

turn the dial until one verification costs ~100 ms you one login 100 ms, once a day — you never notice it but “expensive” measured in arithmetic is what a GPU sells by the thousand … ten thousand cores, each running its own guess 100 ms ÷ 10,000 = the attacker's real cost per guess PBKDF2 and, less so, bcrypt pile on CPU iterations — and iterations are exactly what parallel hardware divides Slowness alone is a cost the attacker can buy their way out of.
The defender pays the full 100 ms per login and the attacker pays it once across thousands of guesses in flight. Any cost made purely of arithmetic gets divided by however many cores the attacker rents, so the work factor is necessary and not sufficient.

4 · The dial they can’t route around

Charge every parallel guess 64 MB of RAM.

a memory-hard KDF fills a big array and reads it back unpredictably so cutting the memory means recomputing so much that the trade stops paying — a penalty, not a prohibition 64 MB per single guess × 10,000 640 GB of RAM, to keep the rig busy cores are cheap; that much RAM is not and here is the ceiling on the whole idea every legitimate login pays the same cost, so the dial stops where your own servers start hurting one second per attempt would harden the hash and hand an attacker a way to exhaust your auth servers with junk logins This is what separates Argon2 and scrypt from the older designs.
This is the parameter that changes what the attacker has to buy rather than how long they wait. Renting ten thousand more cores is easy; renting 640 GB attached to them is the expensive part — and the reason to raise the memory dial before the time dial.

5 · What it still can’t buy

Pricing a guess does nothing about how few guesses there are.

run the arithmetic on your own row, with the numbers from the scene before ~108 word list + an appended digit ÷ 100,000 / sec 10,000 in parallel at 100 ms = ~15 minutes for mountainlion7 The hashing was working perfectly the whole time. it raised the price of one guess by a factor of millions — and the search space was small enough that it did not matter password123 falls in the first second, at any cost setting you could afford to run rate limiting · breach-corpus checks · MFA the work hashing structurally cannot do, because it never gets to change what you chose
Worth being blunt about the limit: a slow hash prices each attempt, and a recognizable pattern is a short list of attempts. Your row survives on the strength of the password as much as on the strength of the KDF, which is why the other three controls are not optional extras.

6 · Keep this card

Three ingredients, three different jobs.

password hash = slow — prices one guess + memory-hungry — the part a GPU farm can’t buy around + per-user salted — kills precomputation ∴ drop the second and a funded attacker just buys more cores and none of the three reduces how many guesses there are — that is a different control
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.

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:

  1. User submits password.
  2. Server fetches (salt, params, stored_hash) for that row.
  3. Server computes Argon2id(password, salt, params) — ~100 ms, ~64 MB.
  4. Constant-time compare against stored_hash.
  5. If params are 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

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.

Going deeper