# The One Job of a Password Hash: Why Fast Is the Enemy, and Memory Is the Frontier

> A password hash has one job: make each guess expensive on the cheapest attacker hardware. Why fast hashes fail, and how bcrypt, Argon2, and yescrypt fix it.

*Published: 2026-07-10*
*Canonical: https://paragmali.com/blog/the-one-job-of-a-password-hash-why-fast-is-the-enemy-and-mem*
*© Parag Mali. All rights reserved.*

---
<TLDR>
**A password hash has exactly one job:** make each attacker guess as expensive as possible on the attacker's *cheapest* hardware, while staying cheap enough on your own server. That is why a fast, strong hash like SHA-256 is the *wrong* tool. A single gaming GPU tries tens of billions of guesses per second, so a stolen fast-hash dump is a race you have already lost -- as LinkedIn learned in 2012. The fix was never a *stronger* hash but a deliberately, tunably *slower* one, and since 2009 a deliberately *memory-hungry* one, because memory is the resource an attacker cannot buy asymmetrically cheaply. This field guide traces the whole deployed lineage -- DES `crypt` (1979), md5crypt, the twin branch of bcrypt (1999) and PBKDF2 (2000), and the memory-hard generation of scrypt (2009), Argon2 (the 2015 competition winner), and yescrypt (now the Linux `/etc/shadow` default) -- alongside the failure catalog that proves the point (LinkedIn, Adobe, Ashley Madison, Facebook). It ends with exact 2024-2026 parameters, a decision tree, and the one rule beneath them all: your security is the weakest function that ever touches the password. Memory-hardness is the recommended default, not a universal mandate -- the right choice is an economic decision bounded by your own budget.
</TLDR>

## 1. Your Database Leaked, and the Race Is Already Over

In June 2012, roughly six million LinkedIn password hashes appeared on a Russian forum, and within days most were plaintext. Not because anyone had broken SHA-1, and not because there was a key to steal, but because the passwords had been stored with a hash whose whole design goal is to be *fast* [@troyhunt].

A single modern gaming GPU tries tens of billions of SHA-1 guesses per second, so a stolen dump of a fast hash is not ciphertext an attacker must break. It is a race the defender has already lost, for every password common enough to sit in a wordlist. The uncomfortable lesson: the property that makes SHA-1 a *good* cryptographic hash -- its blazing speed -- is exactly what makes it a *catastrophic* way to store a password.

Fix that number in your head, because it governs everything that follows: on one NVIDIA RTX 4090, the `hashcat` benchmark clocks SHA-1 at about **50.6 billion** guesses per second and MD5 at **164.1 billion** [@hashcat-4090]. Nobody attacked the mathematics. The attacker simply guessed, very fast, against a list of hashes that were never designed to make guessing expensive.

<Definition term="Stored verifier (password hash)">
A value derived from a password by a deliberately slow, salted, one-way function and stored so that a leaked database does not directly reveal the passwords. It is distinct from a password-derived *encryption key*, which is computed and used but never stored (LUKS, VeraCrypt, password managers -- the subject of Part 9).
</Definition>

<Definition term="Offline attack">
The attack this whole primitive exists to raise the cost of: the adversary holds the stolen verifiers and guesses locally at full hardware speed, with no server and no rate limiter in the loop. Nothing throttles them except the per-guess cost you baked into the hash. This is the offline-attacker threat model the series formalizes in Part 1, "Secure Against Whom?"
</Definition>

So here is the thesis, in your hands from the first page: a password hash has exactly one job -- make each attacker guess as expensive as possible on the attacker's *cheapest* hardware, while staying cheap enough on *your* server. The variable that decided LinkedIn was never SHA-1's strength. It was SHA-1's *speed*, compounded by a missing salt that let identical passwords share a hash and let precomputed tables apply.

Carry one diagnostic sentence through every scheme in this article: *on the cheapest hardware an attacker can rent, how much does one guess at one password cost, and how far above your own per-login cost can you push it?* By the end, you will drop bcrypt, Argon2id, yescrypt, and tomorrow's breach into that one economic question on sight.

> **Note:** A fast hash's cryptographic *strength* -- collision resistance, preimage resistance -- is almost irrelevant to password storage. The only property that matters is cost per guess. That is why one of the best general-purpose hashes ever designed is one of the worst possible password hashes.

One boundary, stated once. This article is about the **stored-verifier** case: turning a low-entropy password into a value you *store* so that a stolen database does not directly yield the passwords. The *same* primitives -- PBKDF2, scrypt, Argon2 -- also serve as key-derivation functions whose output becomes an encryption key and where *nothing is stored* (full-disk encryption, password managers). That dual use is [Part 9's territory](/blog/no-room-for-a-nonce-disk-and-length-preserving-encryption-fr); here it is a pointer, not a detour.

This is also the handoff [Part 10, "A Field Guide to Cryptographic Hashes,"](/blog/the-fingerprint-two-files-shared-a-field-guide-to-cryptograp) pointed at: a general-purpose hash such as BLAKE3 is engineered to be fast, which makes it exactly wrong for storing a password. Use a purpose-built password KDF instead. This is Part 12 of 25, and it is that return.

<Mermaid caption="The offline-cracking loop. A salt forces the attacker to recompute per user, but it sets no speed limit -- for a fast hash the whole loop runs billions of times per second.">
flowchart TD
    A["Stolen dump: one salt and one hash per user"] --> B["Load into hashcat or John the Ripper on a rented GPU rig"]
    B --> C["Take the next guess from a wordlist or a brute-force mask"]
    C --> D["Compute the hash of the guess combined with the stored salt"]
    D --> E&#123;"Equals a stored hash?"&#125;
    E -->|"No"| C
    E -->|"Yes"| F["Password recovered, record it and move on"]
    F --> C
    G["The salt forces a per-user recompute and blocks precomputed tables. It imposes no speed limit. A fast hash runs this entire loop billions of times per second."] -.-> D
</Mermaid>

To make the economics visceral, run the numbers yourself. The rates below are the real measured throughputs of one rented RTX 4090, and the wordlist is the published `rockyou.txt` -- the first list every attacker loads, whose 14,344,391 entries are reproducible with `wc -l` [@seclists-rockyou].

<RunnableCode lang="js" title="How long to try an entire wordlist, per scheme, on one RTX 4090">{`
// Real measured throughputs on one NVIDIA RTX 4090 (hashcat v6.2.6).
var guessesPerSecond = {
  "SHA-1 (fast hash)":            50600000000, // 50.6 GH/s
  "PBKDF2-HMAC-SHA256, 999 it.":  8865700,     // 8.87 MH/s
  "bcrypt (cost 5)":              184000,      // 184 kH/s
  "scrypt (N=16384)":             7126         // 7,126 H/s
};

var wordlist = 14344391; // rockyou.txt: 14,344,391 entries

function human(seconds) {
  if (seconds < 1)     return (seconds * 1000).toFixed(1) + " ms";
  if (seconds < 60)    return seconds.toFixed(2) + " s";
  if (seconds < 3600)  return (seconds / 60).toFixed(1) + " min";
  if (seconds < 86400) return (seconds / 3600).toFixed(1) + " hours";
  return (seconds / 86400).toFixed(1) + " days";
}

for (var scheme in guessesPerSecond) {
  var seconds = wordlist / guessesPerSecond[scheme];
  console.log(scheme.padEnd(30) + human(seconds));
}

console.log("");
console.log("Now add a per-password salt.");
console.log("It blocks precomputed tables and stops two users sharing a hash.");
console.log("Effect on the throughputs above: exactly 0 percent. Same rates, same wall clock.");
`}</RunnableCode>

The fast hash exhausts all fourteen million guesses in well under a second. The memory-hard end of the table takes hours to do the same work on the same silicon -- a gap of roughly seven orders of magnitude, bought entirely by making each guess cost more. And notice what the salt does to those rates: nothing. It is a necessary defense against precomputation and hash-sharing, and it is useless against speed. Speed is the whole vulnerability.

<PullQuote>
A password hash has one job: make every guess cost as much as possible on the attacker's cheapest hardware, while staying cheap on yours. A fast hash gets that job exactly backwards.
</PullQuote>

If the cipher was never the weak link and the key was never touched, then the defense was lost the moment someone chose a *fast* function to store a *guessable* secret. That trade-off -- store something guessable, so make guessing expensive -- was understood with total clarity in 1979, by two people who assumed from the start that the attacker would read the password file.

## 2. 1979: The Three Rules That Still Govern Everything

Bell Labs, 1979. Robert Morris and Ken Thompson publish "Password Security: A Case History" and reason like economists rather than cryptographers [@morris1979]. Their founding assumption is the one every subsequent scheme inherits: assume the attacker can read the password file, because on a shared UNIX system they can. Everything else follows from that single concession.

Three rules fall out of it, and every scheme in this article -- forty-seven years later -- still obeys all three.

<Definition term="Salt">
A per-password random value stored in the clear alongside the hash, so that identical passwords do not produce identical hashes and precomputed tables cannot be reused. A salt is *public*, not secret; its job is to make each stored verifier unique, not to hide anything. Generating salts correctly is the domain of a CSPRNG, covered in Part 2, "Predictable or Repeated."
</Definition>

**Rule one: never store the password -- store a one-way function of it.** A stolen file should be a file of hashes, not a file of passwords. **Rule two: salt it.** Attach a per-password random value -- twelve bits in the original DES `crypt` -- so identical passwords do not collide and an attacker cannot precompute one giant table and apply it to every account at once. **Rule three: make it deliberately slow.** Calibrate the cost so that a legitimate single login is trivial but bulk guessing is not.

The mechanism was concrete, and you can see all three rules in it without touching DES's internals. The password was truncated to eight characters and turned into a 56-bit DES key; that key enciphered an all-zero block twenty-five times over; and the twelve-bit salt perturbed DES's expansion permutation so that off-the-shelf DES hardware could not be pointed straight at the problem [@morris1979]. One-way, because you cannot run DES backward without the key that *is* the password. Salted, because of those twelve bits. Slow, because of those twenty-five iterations.

<Sidenote>That salt design is more pointed than it looks. Morris and Thompson perturbed the DES expansion permutation specifically so an attacker could not use stock DES chips to attack the hashes -- the same "make the attacker's silicon useless" instinct that, thirty years later, becomes memory-hardness [@morris1979].</Sidenote>

The economic point was already fully formed in 1979: the openly readable password file was a *design choice*, so the hash itself had to survive offline guessing. That is the offline-attacker model the whole series leans on, and it is why the strength of the underlying cipher was never the interesting question. The interesting question was cost per guess.

<Mermaid caption="Password hashing at a glance. bcrypt (1999) and PBKDF2 (2000) are contemporaries from different communities, not a before-and-after; the one genuine inflection point is memory-hardness (scrypt, 2009).">
timeline
    title Four decades of stored verifiers
    1979 : Morris and Thompson, DES crypt
    1995 : md5crypt and the Modular Crypt Format
    1999 : bcrypt from the systems world
    2000 : PBKDF2 from the standards world
    2007 : sha256crypt and sha512crypt
    2009 : scrypt, the memory-hard inflection
    2015 : Argon2 wins the PHC, yescrypt recognized
    2016 : Alwen and Blocki bound data-independent MHFs
    2021 : RFC 9106 standardizes Argon2 : yescrypt becomes the Linux shadow default
</Mermaid>

| Founding rule (1979) | What it defeats | How every modern scheme still does it |
|---|---|---|
| Store a one-way function, never the password | A stolen file is not a stolen password list | bcrypt, Argon2, and yescrypt are all one-way; you verify by recomputing and comparing |
| Salt it (per-password random value) | Precomputation and identical-password collisions | a 16-byte CSPRNG salt embedded directly in the stored hash string |
| Make it deliberately slow | Bulk offline guessing | a tunable work factor, iteration count, or memory cost |

<Aside label="Not this article: the password on the wire, and the password abolished">
Two neighbors are easy to confuse with this one, so an expert reader does not think they were missed. Password-authenticated key exchange -- SRP, OPAQUE -- avoids *sending* the password to the server at all; it is a different primitive with a different goal, and it still needs a stored verifier underneath. Passkeys and WebAuthn go the other direction and eliminate the shared secret entirely. Both are pointers here, not sections. And the *same* functions in this article (PBKDF2, scrypt, Argon2) are also used to derive encryption keys for disks and vaults, where nothing is stored -- that is [Part 9](/blog/no-room-for-a-nonce-disk-and-length-preserving-encryption-fr), not this one.
</Aside>

Three rules, 1979: one-way, salted, slow. They were right, and they are still the entire skeleton. But two of the three had hard numbers baked in -- an eight-character limit, a twelve-bit salt, twenty-five fixed iterations -- and numbers calibrated to 1979 hardware do not stay calibrated. The next fifteen years are the story of those ceilings collapsing.

## 3. The Antipattern That Would Not Die

A design calibrated to a single moment ages at the speed of Moore's law. DES `crypt` was tuned for 1979; by the mid-1990s the same twenty-five iterations were a rounding error, and its eight-character truncation threw away everything a longer password could have bought. The fixes that followed solved real problems -- and, in the same breath, set a trap the industry still steps in today.

### Removing the ceilings, and inventing migration

Poul-Henning Kamp's **md5crypt** (`$1$`), created in **1995** in his own words, removed the eight-character and twelve-bit-salt limits with roughly a thousand iterations of MD5 and a larger salt [@phk-eol]. Its most durable gift, though, was not the hash. It was the *format*: the **Modular Crypt Format (MCF)**, a self-describing string of the shape `$<id>$<params>$<salt>$<hash>` that lets one password file hold several algorithms at once and migrate between them over time.

<Definition term="Modular Crypt Format (MCF)">
A self-describing hash string of the form `$<id>$<params>$<salt>$<hash>`. The leading `<id>` names the algorithm, so a verifier can look at a stored value, dispatch to the right function, recompute with the stored parameters, and -- when those parameters fall below today's target -- transparently rehash. It is the reason a single verifier column can hold `$1$`, `$2b$`, `$6$`, and `$argon2id$` values side by side and upgrade in place.
</Definition>

That registry of `<id>` tags is worth memorizing, because it is a map of this entire article:

| MCF prefix | Scheme | Status in 2026 |
|---|---|---|
| `$1$` | md5crypt | legacy, retired by its author [@phk-eol] |
| `$2a$` / `$2y$` / `$2b$` | bcrypt | active [@cryptbf] |
| `$5$` | sha256crypt | active (older Linux default) [@drepper-shacrypt] |
| `$6$` | sha512crypt | active (older Linux default) [@drepper-shacrypt] |
| `$7$` | scrypt | active [@libxcrypt] |
| `$y$` | yescrypt | active (current Linux default) [@libxcrypt] |
| `$argon2id$` | Argon2id | active (cross-platform recommendation) [@rfc9106; @argon2-ref] |

<RunnableCode lang="js" title="Decode a Modular Crypt Format string into its fields">{`
// A Modular Crypt Format value is self-describing: $<id>$<params>$<salt>$<hash>.
// That is exactly what makes in-place migration possible.
function decodeMCF(s) {
  return s.split("$").filter(function (p) { return p.length > 0; });
}

var argon  = "$argon2id$v=19$m=65536,t=3,p=4$c2FsdHNhbHRzYWx0$aGFzaGhhc2hoYXNoaGFzaA";
var bcrypt = "$2b$12$Ro0CUfOqk6cXEKf3dyaM7OhSCvnwM9s4wIX9JeLapehKK5YdLxKcm";

console.log("argon2id ->", JSON.stringify(decodeMCF(argon)));
// ["argon2id", "v=19", "m=65536,t=3,p=4", "<salt>", "<hash>"]

console.log("bcrypt   ->", JSON.stringify(decodeMCF(bcrypt)));
// ["2b", "12", "<22-char salt and 31-char hash, concatenated>"]

console.log("");
console.log("The first token names the algorithm. A verifier dispatches on it,");
console.log("recomputes with the stored parameters, and rehashes if they are stale.");
`}</RunnableCode>

The other move of this era was the SHA-2 bridge. Ulrich Drepper's **sha256crypt** and **sha512crypt** (2007, `$5$` and `$6$`) iterated a NIST-approved hash a default of 5,000 rounds, tunable up to just under a billion, with a salt up to sixteen characters [@drepper-shacrypt]. The motive was compliance as much as cryptography: security teams wanted a NIST-tested primitive, which ruled out Blowfish-based bcrypt and made sha512crypt the long-time Linux `/etc/shadow` default. Same instinct as md5crypt -- make the cost tunable -- with a FIPS-friendly hash underneath.

### The antipattern, named and buried

Now the recurring villain of the whole story: the **fast, unsalted, general-purpose hash** -- raw MD5, SHA-1, or SHA-256 -- pressed into service as a password store. It fails on both axes at once. *Unsalted* means precomputed rainbow tables apply and identical passwords share a hash. *Fast* means billions of guesses per second.

This is precisely the LinkedIn disaster from Section 1 -- unsalted SHA-1, plaintext in days [@troyhunt] -- and, one rung worse, **RockYou** in 2009, which stored roughly 32.6 million passwords in *plaintext*, no hash at all [@imperva-rockyou]. That dump became `rockyou.txt`, the canonical cracking wordlist now itself part of the arms race.

<MarginNote>The unsettling corollary for defenders: if your password is memorable, it is almost certainly already a line in that cracking wordlist, tried in the first seconds of any offline attack.</MarginNote>

> **Key idea:** The fast-hash trap: the property that makes SHA-256 a *good* general-purpose hash -- blazing speed -- is exactly what makes it a *catastrophic* password hash. A salt defeats precomputation and hash-sharing, but it changes the attacker's throughput by exactly zero. The attacker never breaks the hash; a stolen fast-hash dump is a race already lost.

> **Note:** Never store a bare fast hash of a password -- not MD5, not SHA-1, not SHA-256, salted or not. A salt is necessary and it stops precomputation, but it does nothing about speed, and speed is the entire vulnerability. This single antipattern is the root cause of most of the failure catalog below.

Kamp himself buried md5crypt in 2012, and his exact words matter because the field has misquoted them ever since. His notice is titled "Md5crypt Password scrambler is no longer considered safe by author," and it reads, plainly, "I implore everybody to migrate to a stronger password scrambler without undue delay" [@phk-eol]. The widely repeated phrase "unfit for use" is a paraphrase, not his language. What he *did* write is startling in hindsight: the replacement should "soak up area in hardware based attack implementations" -- a demand for area-cost hardening, seven years before scrypt would make it a formal property.

<Sidenote>Kamp's 2012 retirement of md5crypt cited about one million guesses per second on a commodity 2012 GPU and the vulnerability CVE-2012-3287. Read his phrasing carefully -- "soak up area in hardware based attack implementations" is a plea for area-time cost, the exact idea memory-hardness would formalize [@phk-eol].</Sidenote>

<PullQuote>
The successor should "soak up area in hardware based attack implementations." -- Poul-Henning Kamp, retiring md5crypt in 2012, describing memory-hardness before it had a name.
</PullQuote>

Keep this master catalog in view; the article returns to each entry and unpacks the interesting ones in Sections 9 through 11. Every row is the same lesson from a different angle.

| Incident | Year | What was stored | Root cause | The lesson |
|---|---|---|---|---|
| RockYou | 2009 | Plaintext | No hash at all | The floor of the failure catalog [@imperva-rockyou] |
| LinkedIn | 2012 | Unsalted SHA-1 | Fast and unsalted | Speed is the vulnerability [@troyhunt] |
| Adobe | 2013 | Reversible 3DES in ECB mode | Encryption, not hashing | Hash, never encrypt, a password [@adobe-ars2013] |
| Ashley Madison | 2015 | bcrypt cost-12, plus an MD5 token | A strong hash undone by a parallel fast path | You are only as strong as the weakest function [@cynosure-am] |
| Dropbox | 2016 | SHA-512, then bcrypt-10, then AES-256 | A *good* example, shown for contrast | Layer defenses, keep a separate pepper [@dropbox] |
| Facebook | 2019 | Plaintext, in log files | Plaintext logging beside a good hash | Audit the whole path, not the column [@krebs-fb2019] |
| Password shucking | 2020 | `bcrypt(md5(pw))`, unsalted inner | A naive pre-hash | Never feed an unsalted fast hash into bcrypt [@scottbrady-shucking] |
| crypt_blowfish bug | 2011 | bcrypt with an 8-bit character flaw | Sign-extension truncation | Why bcrypt has `$2x$` and `$2y$` tags [@cryptbf] |

md5crypt and sha512crypt fixed 1979's *ceilings* but kept its third rule frozen in place: a *fixed* iteration count chosen once and forgotten. The next idea was to stop freezing the cost and make it a *knob* the defender could turn as hardware improved. That idea arrived twice, independently, in two different communities within about a year -- and the blind spot those twins shared is where the modern story actually begins.

## 4. A Branch, Not a Line: bcrypt and PBKDF2 Are Twins

The textbook story is a clean relay race: each generation hands the baton to a stronger successor that retires it. For password hashing, that story is *wrong*, and getting it right is the single most useful correction an expert reader can take from this article. Around 1999 and 2000, two communities answered the same question -- "make the cost a tunable knob" -- independently, and *neither replaced the other*. They are twins from different families, and both are still alive in 2026.

### bcrypt, from the systems world (1999)

Niels Provos and David Mazieres built bcrypt for OpenBSD on top of **Eksblowfish**, an "expensive key schedule" variant of the Blowfish cipher [@bcrypt1999]. The construction repeats Blowfish's key setup $2^{\text{cost}}$ times, then enciphers the constant string "OrpheanBeholderScryDoubt" sixty-four times, salted with 128 bits and encoded as `$2a$<cost>$...`. The innovation is the **adaptive cost factor**: raise `cost` as hardware improves, without changing the algorithm or invalidating a single stored hash. In the authors' own framing, "the computational cost of any secure password scheme must increase as hardware improves" [@bcrypt1999].

<Definition term="Work factor (adaptive cost)">
A tunable parameter -- bcrypt's `cost`, PBKDF2's iteration count, Argon2's time and memory knobs -- that raises the per-guess cost so a defender can track hardware improvements over time without changing the algorithm or re-hashing existing verifiers with a new scheme. Turning it up is how you "restore the cost ratio" the spine keeps referring to.
</Definition>

bcrypt was also, almost by accident, the first widely deployed scheme to *touch memory* in a way that mattered. Blowfish's key schedule builds about 4 KiB of S-boxes, and that small working set already made bcrypt meaningfully harder to parallelize on a GPU than a bare hash -- a fact scrypt's paper would later quantify [@scrypt-paper]. Two structural limits recur later: bcrypt silently truncates its input at **72 bytes**, and its `$2a$` / `$2x$` / `$2y$` / `$2b$` version tags are a bug-fix lineage, not a version-number vanity.

<Sidenote>bcrypt's prefix zoo is a repair history. The `$2x$` and `$2y$` tags came out of a 2011 fix to an 8-bit-character sign-extension flaw in `crypt_blowfish` (CVE-2011-2483) that could truncate the input; `$2b$` matches the behavior of OpenBSD 5.5 and later [@cryptbf].</Sidenote>

### PBKDF2, from the standards world (2000)

Burt Kaliski's **PBKDF2** came out of RSA Laboratories as part of PKCS #5 v2.0, later republished as RFC 2898 and updated in RFC 8018 [@rfc2898; @rfc8018]. Its design is deliberately plain: iterate a pseudorandom function -- in practice HMAC over an approved hash -- a count `c` times over the password and salt, concatenating output blocks. Because it is built entirely from NIST-approved hashes, it is the option a **FIPS-constrained** system can actually deploy, which Blowfish-based bcrypt is not. In 2026 it remains the mandated path wherever FIPS applies.

Its weakness has to be stated precisely, because the folklore version is wrong. PBKDF2 is not weak because "GPUs are fast." It is weak because it has **negligible, constant memory** and is otherwise a bare loop of HMAC, which makes it the *most* parallelization-friendly of every deployed scheme. The crux is **low, constant memory implies cheap, massive parallelism** on GPU, FPGA, and ASIC -- an attacker fits thousands of independent PBKDF2 evaluations onto one chip because each one needs almost no RAM.

<Sidenote>A nuance the OWASP floors encode: PBKDF2-HMAC-**SHA-512** does 64-bit-word arithmetic, which is somewhat *less* GPU-friendly than SHA-256's 32-bit words. That is exactly why OWASP's SHA-512 iteration floor (210,000) sits below its SHA-256 floor (600,000) -- fewer iterations buy the same attacker cost [@owasp].</Sidenote>

<Aside label="Branch, not line: the correction that matters most">
bcrypt (1999) and PBKDF2 (2000) are *contemporaries* from different communities, not a before-and-after. sha256crypt and sha512crypt (2007) are a third sibling. And **no earlier scheme was ever cleanly retired**: Linux ran DES `crypt`, then md5crypt, then sha512crypt for decades, and PBKDF2 is still the FIPS default today. Read the "generations" of password hashing as geological strata that *accreted*, not as a relay race where each runner sits down. The only true inflection point in the whole history is memory-hardness (scrypt, 2009).
</Aside>

Here is the master head-to-head. The rightmost columns are the ones that decide real deployments; the article earns each judgment in the sections that follow.

| Scheme | Year | New cost lever | Memory-hard? | FIPS lane? | Side-channel posture | Status in 2026 |
|---|---|---|---|---|---|---|
| Raw fast hash (MD5, SHA-1, SHA-256) | -- | none; general-purpose speed | No | primitive only | n/a | never use for passwords |
| DES `crypt(3)` | 1979 | one-way, salt, 25 iterations [@morris1979] | No | No | data-independent | historic |
| md5crypt (`$1$`) | 1995 | ~1000 iterations, MCF [@phk-eol] | No | No | data-independent | retired by author |
| bcrypt | 1999 | adaptive cost, expensive key schedule [@bcrypt1999] | No (touches ~4 KiB) | No | n/a (not an MHF) | active |
| PBKDF2 | 2000 | iterated HMAC, tunable count [@rfc2898] | No (negligible memory) | Yes | data-independent | active (FIPS) |
| sha256crypt / sha512crypt | 2007 | tunable SHA-2 rounds [@drepper-shacrypt] | No | Yes | data-independent | active (legacy default) |
| scrypt | 2009 | sequential memory-hardness [@scrypt-paper] | Yes (data-dependent) | No | data-dependent | active |
| Argon2i | 2015 | data-independent MHF [@rfc9106] | Yes (provably suboptimal) | No | data-independent | superseded by id |
| Argon2id | 2015 | hybrid i-then-d MHF [@rfc9106] | Yes | No | hybrid | recommended default |
| yescrypt | 2015 | scrypt plus pwxform, optional ROM [@yescrypt] | Yes (data-dependent) | No | data-dependent | Linux shadow default |

### The blind spot all three twins shared

Every one of the 1999-2007 schemes stretches **time only**, with a small fixed memory footprint. Time-hardness has a fatal asymmetry: a function that is slow but tiny in memory is *cheap to parallelize*. The defender runs one core per login; the attacker runs thousands of small cores on a GPU or millions in an ASIC. "Slower per guess" is a race the defender loses at scale.

One benchmark makes the asymmetry impossible to unsee -- a single RTX 4090 under `hashcat` v6.2.6 [@hashcat-4090]:

| Hash mode (one RTX 4090) | Guesses per second | Slowdown vs MD5 |
|---|---|---|
| MD5 | 164.1 billion | 1x |
| SHA-1 | 50.6 billion | about 3x |
| PBKDF2-HMAC-SHA256, 999 iterations | 8.87 million | about 18,000x |
| bcrypt, cost 5 | 184,000 | about 890,000x |

<Mermaid caption="The spine as a staircase. Each lever is eroded by a new attacker capability and restored by a new, harder-to-parallelize cost. This is the same economic move, repeated for forty years.">
flowchart LR
    A["Lever 1: iteration TIME<br/>DES crypt 1979, md5crypt 1995"] -->|"eroded by faster CPUs"| B["Lever 2: expensive KEY SCHEDULE<br/>bcrypt 1999"]
    B -->|"eroded by cheap parallel GPU, FPGA, ASIC"| C["Lever 3: MEMORY<br/>scrypt 2009, Argon2 2015"]
    C -->|"eroded by botnets and cheap RAM"| D["Lever 4: MEMORY plus a site ROM<br/>yescrypt"]
</Mermaid>

Look at the last two rows. PBKDF2 with a thousand iterations barely dents the attacker's throughput, because it is almost pure compute. bcrypt at a low cost of 5 -- with nothing but its 4 KiB of S-boxes -- already costs roughly *five orders of magnitude* more per guess than a raw hash, purely because it touches memory and cannot be trivially unrolled. That gap is the entire clue.

If a mere 4 KiB of forced memory buys bcrypt that five-orders-of-magnitude edge over a raw hash, the lever is obvious in hindsight: stop making a guess take *longer* and start making it take *more room*. CPU cycles and logic gates are cheap and endlessly parallel. Memory bandwidth and capacity are not. That inversion -- from time to memory -- is the one genuine conceptual leap in this entire history.

## 5. The Breakthrough: Make Memory the Bottleneck

In 2009, Colin Percival stated the idea that splits the timeline in two: force the computation to *hold a large array in RAM for a long time*, so that computing the hash cheaply *requires buying memory* -- the one resource an attacker with GPUs, FPGAs, and ASICs cannot parallelize away [@scrypt-paper]. This is not a faster hash. It is a differently shaped one.

### scrypt and sequential memory-hardness

The core of scrypt is a routine called **ROMix**, and you can understand it without touching the Salsa20 mixing inside. It runs in two phases. In the **fill** phase, it writes an array `V[0..N-1]` into memory, where each block is the previous block run through a mixing function -- a strictly sequential chain. In the **mix** phase, it takes `N` more steps, and on each step it reads `V[j]` at an index `j` that *depends on the data computed so far*, mixing that block back into a running state [@scrypt-paper; @rfc7914].

That second phase is the whole trick. Because the read indices depend on the data, you cannot know in advance which blocks you will need, so computing scrypt with fewer than `N` blocks resident forces you to *recompute* missing blocks on demand -- and that recomputation cascades.

To keep the work down, you must hold about `N` blocks for about `N` steps: an **area times time** cost of roughly $AT \approx \Theta(N^2)$. Halving the memory you hold roughly *doubles* the time, so the AT product stays near $\Theta(N^2)$. Double the cost parameter `N` instead and both time and memory double, so the attacker's hardware cost rises four-fold [@scrypt-paper].

<Definition term="Memory-hard function (MHF)">
A function deliberately designed so that computing it cheaply *requires* holding a large amount of memory for a large fraction of its running time. The point is not that it uses memory, but that no attacker strategy avoids the memory without paying more somewhere else -- imposing a cost that cheap, massively parallel hardware cannot dodge.
</Definition>

<Definition term="AT-cost (area times time)">
The attacker's real cost metric for a memory-hard function: the memory it occupies multiplied by the time it is held, a proxy for the silicon area and energy one guess consumes. Time-hardness alone raises only the time term; memory-hardness raises the product. Maximizing AT-cost per guess is the entire objective of the memory-hard program.
</Definition>

<Mermaid caption="scrypt's ROMix: fill an array of N blocks, then walk it at data-dependent indices. Holding fewer than N blocks forces cascading recomputation, which is what pins the area-times-time cost near N squared.">
flowchart TD
    A["Phase 1, FILL. Write N blocks into RAM, each derived from the previous block"] --> B["The whole array of N blocks now sits resident in memory"]
    B --> C["Phase 2, MIX. Repeat N times"]
    C --> D&#123;"Read block j, where j depends on the data so far"&#125;
    D --> E["Mix that block into the running state"]
    E --> C
    D -.->|"Hold fewer than N blocks and you must recompute them"| F["Less memory forces far more work, so AT stays high"]
</Mermaid>

The payoff shows up on the same RTX 4090 that cracked SHA-1 at 50.6 billion guesses per second. scrypt tuned to `N` of 16384 runs at just **7,126** guesses per second on that hardware [@hashcat-4090] -- about 25 times slower than bcrypt at cost 5, and roughly *seven orders of magnitude* below raw MD5, because the GPU's thousands of cores now fight over scarce fast memory instead of streaming through cheap arithmetic.

> **Key idea:** Time-hardness is cheap to parallelize: the defender runs one core, the attacker runs thousands, so making a guess merely *slower* loses the cost race at scale. Making a guess *occupy memory for a long time* -- an area-times-time cost -- targets the one resource an attacker cannot buy asymmetrically cheaply. That inversion, from time to memory, is the modern era of password hashing.

### Institutionalizing it: the PHC and Argon2

Faced with a hard design problem, cryptography did what it does best -- it ran an open competition. The **Password Hashing Competition** ran from 2013 to 2015, initiated by Jean-Philippe Aumasson, drew 24 submissions, and was judged by a panel in the same spirit as NIST's AES and SHA-3 contests [@phc]. It named one winner, **Argon2**, and gave special recognition to four finalists.

<Sidenote>The PHC's four special-recognition finalists were Catena, Lyra2, Makwa, and yescrypt -- the last of which went on to become the Linux `/etc/shadow` default. The competition format, modeled on NIST's, is why the field trusts Argon2: it survived years of public cryptanalysis, not a vendor's say-so [@phc].</Sidenote>

Argon2 is the purpose-built memory-hard function. It exposes three *independent* knobs -- time `t` (passes), memory `m` (in KiB), and parallelism `p` -- fills and mixes a large array using a BLAKE2b-based compression function, and encodes its output in MCF as `$argon2id$v=19$m=...,t=...,p=...$salt$hash`, where `v=19` is version 0x13. The reference defaults are `t=3`, `m=4 MiB` (that is $2^{12}$ KiB), and `p=1` [@argon2-ref]. Crucially, Argon2 comes in three variants, and choosing among them is a *threat-model* decision, not a performance tweak.

<Definition term="Data-independent (iMHF) versus data-dependent (dMHF) memory access">
Whether the memory addresses a function reads depend on the password. **Data-independent** (an iMHF) is cache-timing-safe, because the access pattern reveals nothing about the secret -- but, as the next section proves, it cannot be optimally memory-hard. **Data-dependent** (a dMHF) can reach the ideal hardness bound but leaks information through cache-timing side channels. This tension is the reason Argon2id exists.
</Definition>

<Mermaid caption="Argon2's three variants map to two threat models. Argon2id runs data-independent addressing for the first half-pass, then switches to data-dependent, which is why RFC 9106 makes it the must-support choice.">
flowchart TD
    P["Password plus salt"] --> ID&#123;"Which Argon2 variant?"&#125;
    ID -->|"Argon2d"| D["Data-dependent indexing. Maximum GPU and trade-off resistance, but the access pattern leaks through cache timing"]
    ID -->|"Argon2i"| I["Data-independent indexing. Cache-timing safe, but provably attackable, needs more passes"]
    ID -->|"Argon2id"| H["Hybrid. Data-independent for the first half-pass, then data-dependent"]
    H --> A["Answers both threat models, so RFC 9106 makes it must-support"]
</Mermaid>

**Argon2d** uses data-dependent indexing for maximum resistance to GPU and trade-off attacks, at the cost of a cache-timing side channel. **Argon2i** uses data-independent indexing, which is cache-timing-safe but needs more passes and is provably weaker. **Argon2id** is the hybrid: data-independent addressing for the first half-pass, where the side channel matters, then data-dependent addressing for the rest. RFC 9106 makes **Argon2id the MUST-support variant** [@rfc9106], for reasons the theory section will make exact.

Memory-hardness works. It is the first idea in thirty years that changes the *shape* of the attacker's cost, not merely its size. So the modern era ought to be simple -- pick the memory-hard winner, turn the memory knob to maximum, and go home. It is not simple, and the two reasons why -- one you meet in production, one buried in complexity theory -- are the rest of this article.

## 6. State of the Art (2024-2026): Two Defaults, Not One

Ask a working engineer "what should I use in 2026?" and the honest answer is two names, not one -- and which one depends on what you are protecting and where it runs.

### Argon2id: the recommended cross-platform default

For greenfield application code, Argon2id is the choice. It is the RFC 9106 MUST-support hybrid and the OWASP recommendation, and it meets NIST SP 800-63B-4's generic requirements -- though NIST itself names no specific memory-hard scheme, and its only approved-scheme pointer is SP 800-132 (PBKDF2) [@nist-63b]. Its one algorithm answers *both* threat models at once: the data-independent first half-pass closes the cache-timing side channel, and the data-dependent tail resists GPU and ASIC trade-offs. You get independent time, memory, and parallelism knobs, plus a self-describing MCF encoding that makes migration a matter of reading the stored parameters [@rfc9106; @argon2-ref].

Two authoritative parameter sets anchor it. RFC 9106's **first recommended** option is `t=1`, `p=4`, `m=2 GiB`, a default suitable for all environments; its memory-constrained **second** option is `t=3`, `p=4`, `m=64 MiB` [@rfc9106]. OWASP publishes a much lower *floor* -- `m=19 MiB`, `t=2`, `p=1` -- and it is exactly that: a bare minimum to clear, not a target to aim at [@owasp].

Where Argon2id struggles is the mirror image of its strength. The defender pays the full memory cost on *every* login, so a 2 GiB-per-hash setting is a genuine RAM and denial-of-service hazard -- which is precisely why the deployed floors sit so far below the theoretical ideal. Some language bindings also default to the weaker Argon2i or to under-powered parameters, so "we use Argon2" is not the same claim as "we use Argon2id at sound parameters."

### yescrypt: the Linux `/etc/shadow` default

Alexander Peslyak's yescrypt, from Openwall, is what actually hashes the login password on most modern Linux systems. It builds on scrypt's sequential memory-hardness and adds **pwxform** -- S-box lookups interleaved with integer multiplication that annoy GPUs, FPGAs, and ASICs even at *low* memory settings -- plus an optional large, site-specific **ROM** of tens of gigabytes that throttles "attackers' use of pre-existing hardware such as botnet nodes" [@yescrypt].

It is engineered so its effective memory cost cannot be driven "unreasonably low" the way plain scrypt's and Argon2's can be at high throughput and low latency. That is exactly why it dominates the high-request-rate regime an OS login endpoint lives in [@yescrypt].

<Sidenote>yescrypt is the default `/etc/shadow` scheme on ALT Linux, Arch, Debian 11 and later (it became the default in 11, not 12), Fedora 35 and later, Kali 2021.1 and later, and Ubuntu 22.04 and later; it is also *supported* but not default on Fedora 29+, RHEL 9+, and Ubuntu 20.04+. The selection mechanism is libxcrypt together with `login.defs` [@yescrypt; @libxcrypt].</Sidenote>

Where yescrypt struggles is breadth: it is Linux-centric, comparatively young, and thin on non-Linux bindings, and its ROM feature demands real operational machinery -- the ROM must *never* be lost, because losing it invalidates every hash computed against it.

<MarginNote>Beware a naming collision before you grep a Windows codebase: the `BCrypt*` functions in Windows CNG (Cryptography API: Next Generation) are *not* the bcrypt password hash -- they are the platform's general crypto API [@microsoft-cng]. See the [CNG architecture post](/blog/cng-architecture-bcrypt-ncrypt-ksps) for what those calls actually do.</MarginNote>

### The honest status line

scrypt (still widely deployed, standardized as RFC 7914), bcrypt (ubiquitous and battle-tested), and PBKDF2 (the FIPS lane) are all **active, not retired**. The branch-not-line reality of Section 4 persists straight into the present: there is no single winner that made the others obsolete, only a recommended default and a set of live alternatives chosen by constraint.

> **Note:** Argon2id at RFC 9106 parameters for greenfield application code. Leave yescrypt alone on modern Linux -- it is already the `/etc/shadow` default, so do not reinvent it. bcrypt at work factor 10 or higher for small, legacy, or simplicity-first services. PBKDF2-HMAC-SHA256 at 600,000 iterations only when FIPS forces your hand.

"Use Argon2id, or leave yescrypt alone" is the entire answer for new code on a known platform. But engineers inherit constraints -- a regulator that forbids the primitive, a runtime with no good binding, a login endpoint one traffic spike away from falling over. So the real question is not "what is best" in the abstract. It is "what are my options, ranked, and exactly when does each one apply?"

## 7. The Decision Matrix: Use X With These Parameters in Case Y

Five deployed schemes, one page, ranked -- not by fashion but by the economic yardstick this whole article is built on: how much does one attacker guess cost, and what does imposing that cost do to you? The master matrix in Section 4 lays out the axes that decide it -- memory-hardness, standardization and FIPS status, side-channel posture (data-dependent versus data-independent), library breadth, and tunability. What follows turns those axes into rules you can apply on sight.

| Your binding constraint | Use this | At these parameters |
|---|---|---|
| Greenfield app, good library | Argon2id [@rfc9106] | `2 GiB, t=1, p=4` if affordable; else `64 MiB, t=3, p=4`; never below the `19 MiB, t=2, p=1` floor [@owasp] |
| Linux OS login | yescrypt [@yescrypt] | the distro default; do not hand-roll it [@libxcrypt] |
| Huge single-operator, can run a ROM | yescrypt with ROM [@yescrypt] | raise the attacker's *capital* cost in scarce large RAM |
| Need memory-hardness, no Argon2id | scrypt [@owasp] | `N=2^17, r=8, p=1` (about 128 MiB) |
| FIPS-140 or regulatory constraint | PBKDF2-HMAC-SHA256 [@owasp] | at least `600,000` iterations |
| Legacy, small, or simplicity-first | bcrypt [@owasp] | work factor 10 or higher; enforce the 72-byte cap |

Read the axes together and the rules explain themselves. If you need cache-timing safety on a shared or multi-tenant host, you want a data-independent or hybrid scheme, which points at Argon2id over Argon2d or scrypt. If a regulator dictates approved primitives, memory-hardness is off the table entirely and PBKDF2 is the only compliant answer, whatever its per-guess weakness.

If your constraint is a login endpoint hammered at high request rates, yescrypt's low-memory hardening earns its keep. And if your real constraint is a small team that needs one dependency-light library that has existed for two decades, bcrypt is a defensible engineering choice, not a mistake.

That last point is the one that makes this section trustworthy, so state it plainly: **the ranking is not absolute.** A well-tuned bcrypt on a small service can out-secure an *under-provisioned* Argon2id that a denial-of-service-averse operator was forced to dial down to the 19 MiB floor. The most credible voice on this is not a bcrypt partisan -- it is the author of yescrypt, the very scheme that sits at the top of the memory-hard heap.

<PullQuote>
"For smaller deployments, bcrypt with its simplicity and existing library support is a reasonable short-term choice." -- Alexander Peslyak, author of yescrypt [@yescrypt]
</PullQuote>

FIPS deserves one more word, because it does something the rest of the matrix does not: it *collapses* the whole decision to a single cell. Under a FIPS-140 constraint, none of the memory-hard reasoning applies -- Argon2, scrypt, and yescrypt are simply not approved -- and you deploy PBKDF2-HMAC-SHA256 at a high iteration count and accept its parallelism weakness as the price of compliance [@owasp]. The economic yardstick still governs; the regulator has just fixed one of its variables for you.

The matrix ranks the schemes, but notice what it quietly concedes. Every "best" here is bounded by what the *defender* can afford to run on every single login, and every entry leans on assumptions about attacker hardware that no one can hold fixed across a decade. Pull on that thread and you arrive at the theory -- where memory-hardness turns out to carry a hard, *proven* ceiling that no amount of engineering can lift.

## 8. Theoretical Limits: Memory-Hard by Design Is Not Optimally Memory-Hard

Password hashing is one of the rare corners of applied cryptography with real complexity theory bolted to it -- and the theory delivers two humbling results the marketing never mentions.

### The ideal, and the impossibility

The best conceivable attacker cost for a function that touches `n` blocks over `n` steps is $AT = \Theta(n^2)$: you pay the full memory-time area even with unlimited parallel cores. scrypt's *data-dependent* ROMix is designed to reach it. But you cannot have all three of $\Theta(n^2)$ hardness, cache-timing safety, and free parameter choice at once -- and that is a theorem, not a limitation of any particular design.

Joel Alwen and Jeremiah Blocki proved that *data-independent* memory-hard functions -- the class you are forced into for cache-timing safety, because their access pattern must not depend on the password -- **provably cannot reach the ideal bound**. Any iMHF can be computed at $O(n^2/\log^{1-\varepsilon} n)$, and Argon2i specifically at $O(n^{7/4}\log n)$ [@alwen-blocki2016]. That sublinear shortfall is small, but it is real, and it is unavoidable for the whole data-independent class.

This is the single most consequential theorem in the area, because it is the actual reason RFC 9106 prefers the **Argon2id hybrid** over the safer-*looking* pure-data-independent Argon2i. Recall the addressing diagram from Section 5: Argon2id uses data-independent addressing only for the first half-pass, where the side channel would otherwise leak, then switches to data-dependent addressing, which escapes the iMHF impossibility.

The RFC bakes this in directly. It makes Argon2id MUST-support, notes that Argon2i "makes more passes over the memory to protect from trade-off attacks," and leans on the Alwen and Blocki attack -- its reference tag `[AB16]` -- to conclude that three passes is "almost optimal" for Argon2i [@rfc9106; @alwen-blocki2016].

The follow-on results fill in the picture, and they are worth one breath each so the section stays proportionate. Blocki and Zhou tightened the Argon2i bound to $O(n^{1.768})$ from above and $\Omega(n^{1.75})$ from below [@blocki-zhou2017]. **DRSample** is the best practical iMHF construction known, built for high depth-robustness [@drsample2017]. **Balloon** was the first practical iMHF to ship with a memory-hardness proof [@balloon2016].

On the data-dependent side, the attacks are only *constant-factor* trade-offs found by real cryptanalysis rather than asymptotic breaks -- Biryukov and Khovratovich, for instance, cut Catena's AT-cost by a factor of about 25 using $M^{4/5}$ memory [@biryukov-khovratovich2015]. The map is detailed, but the headline is simple: the class you need for side-channel safety is the class that cannot be optimal, so the field hybridizes.

### The second, mundane impossibility: no free lunch

The other limit needs no complexity theory at all. The defender pays the *same* per-evaluation cost as one attacker guess. So the hardness you can impose is capped by *your own* budget: the RAM you can spare per concurrent login, the hash latency your users will tolerate (roughly one second at the very most), and the denial-of-service surface you expose when an attacker hammers your login endpoint to make you do the expensive work.

RFC 9106 frames the rational defender not as someone climbing an unbounded hardness curve but as someone maximizing attacker cost *for a fixed defender time budget* -- an optimization with a ceiling, not a dial that goes to infinity [@rfc9106].

> **Note:** You cannot make a password hash arbitrarily strong. You can only make it as strong as you can afford to run on *every* login, because your per-evaluation cost equals one attacker guess. Hardness is bounded by your RAM times concurrency, your latency tolerance, and your denial-of-service budget -- not by the algorithm.

<Sidenote>Quantum computing does not change this calculus either. The Section 11 FAQ carries the full argument; in short, Grover's square-root speedup is no Shor-style break against a one-way hash and is swamped by the per-guess costs a memory-hard scheme already imposes, so post-quantum worry belongs to your key exchange, not your password store [@nist-ir-8105].</Sidenote>

> **Key idea:** The humbling synthesis: "memory-hard by design" is not "optimally memory-hard" -- Alwen and Blocki prove the data-independent class cannot reach the ideal AT bound, which is *why* Argon2id is a hybrid. The defender pays the memory too, so hardness is bounded by your own budget. And the hash is rarely the weakest link anyway. The verdict is not "use the strongest function" but "price a guess correctly, on both sides of the ledger" -- an economic decision, not a single winner.

So the design problem has a proven best answer -- the id hybrid -- *and* a proven ceiling -- your own budget. Yet scanners still find broken password storage in production every year, and the field still argues, in public, about whether any of this memory-hardness is worth paying for. Both of those live on the open frontier.

## 9. Open Problems: The Arms Race, and the Argument About Whether to Fight It

The construction-level question is largely settled -- use the id hybrid, and the theory tells you why. The *deployment* question is wide open, and one of its honest sub-questions is whether the field's own default is worth its price.

**Principled parameter selection under real budgets.** The question every engineer actually has -- given RAM times concurrency, a latency target, login queries per second, and a denial-of-service tolerance, output the AT-optimal `(m, t, p)` -- has no closed-form answer. RFC 9106 offers two fixed configurations; OWASP offers equivalence classes; neither is a portable optimizer you can point at your server and trust [@rfc9106; @owasp]. In practice you measure, pick a latency target, and revisit it, which is craft, not calculation.

**The moving hardware economics.** Every parameter floor silently assumes an attacker hardware model, and that model drifts. When a new GPU generation gains memory bandwidth, yesterday's "19 MiB minimum" quietly weakens without a line of your code changing. The RTX 4090 benchmark in this article is one snapshot of a gradient that shifts every hardware generation [@hashcat-4090]. yescrypt's optional ROM is one structural answer: raise the attacker's *capital* cost -- the price of enough scarce large RAM -- rather than only the marginal cost per guess [@yescrypt].

**Peppering and secret-salt key management.** A server-held secret mixed into the hash makes a stolen database uncrackable *without* also stealing a separately stored key -- a large practical win. But get the construction wrong and you cannot ever rotate that secret.

<Definition term="Pepper (secret salt)">
A *secret*, server-held value -- kept in an HSM, KMS, or TEE, separate from the database -- mixed into the hash as defense-in-depth, so that a stolen database *alone* cannot be cracked. It is distinct from the public per-password salt: the salt makes verifiers unique and is stored beside them; the pepper is a secret and must never be.
</Definition>

The engineering answer is to *encrypt* the hash under the pepper (which is rotatable, the design Dropbox shipped) rather than to HMAC it into a one-way step (which is not). NIST SP 800-63B-4 recommends exactly this: an additional keyed hashing or encryption operation with the key held in a hardware-protected area such as an HSM or TPM [@dropbox; @nist-63b]. The how-to is in Section 10.

<Aside label="Is memory-hardness worth its cost? The honest debate">
Present both sides, because reasonable experts disagree. **For:** the AT-cost theory and the benchmark gradient are real -- scrypt costs an attacker orders of magnitude more per guess than PBKDF2, and bcrypt sits in between [@scrypt-paper; @hashcat-4090]. **Against, for small deployments:** yescrypt's *own author* endorses bcrypt as a reasonable short-term choice, and Dropbox shipped plain bcrypt at cost 10 plus a global pepper and called it among the strongest approaches in production [@yescrypt; @dropbox]. And the sharpest point of all: the failure catalog shows the *usual* weakest link is almost never the hash's memory parameter. Ashley Madison's genuinely strong bcrypt at cost 12 fell because the same database also stored a fast MD5 token, `md5(lc($username)."::".lc($pass))`, that CynoSure Prime cracked in hours and then case-corrected against the bcrypt values [@cynosure-am]. Facebook logged between 200 and 600 million passwords in *plaintext*, searchable by some 20,000 employees [@krebs-fb2019]. Adobe *encrypted* about 130 million passwords with reversible 3DES in ECB mode instead of hashing them [@adobe-ars2013]. And password shucking turns a naive `bcrypt(md5($pw))` into a fast-hash problem: attacking the bcrypt directly runs near 2,000 guesses per second, but the shucked MD5 runs near 64 *billion* [@scottbrady-shucking]. In every case, memory-hardness would have changed nothing. The correct framing is economic, not dogmatic: memory-hardness is the recommended *default*, not a universal mandate.
</Aside>

Every open problem here reduces to the sentence this article began with -- maximize attacker cost per guess without bankrupting the defender -- now carrying one humbling clause: the hash is only *one* term in a much larger security budget, and often not the term that decides the outcome. Which means the practical guide is not a victory lap. It is that sentence, made operational.

## 10. The Practical Guide: Decision Rules, Parameters, and Shipping Code

Everything above collapses into five short things: a decision procedure, a parameter table, the concrete library call for each scheme, a list of patterns to never ship, and the login-handler code the whole article has been building toward. None of it requires re-deriving the theory. It requires knowing your one binding constraint and reading down from there.

Start with the constraint, because it -- not fashion -- picks the scheme. Walk this tree top to bottom and stop at the first branch that matches your situation.

<Mermaid caption="The 2026 decision tree. Walk it top to bottom and stop at the first constraint that applies; each leaf names the scheme and the parameters to start from.">
flowchart TD
    S["Start: choosing a password hash"] --> F&#123;"FIPS or regulatory constraint on primitives?"&#125;
    F -->|"Yes"| FP["PBKDF2-HMAC-SHA256, at least 600,000 iterations"]
    F -->|"No"| L&#123;"Hashing the Linux /etc/shadow login?"&#125;
    L -->|"Yes"| LY["yescrypt, the distro default via libxcrypt. Do not reinvent it"]
    L -->|"No"| G&#123;"Greenfield app with a good library?"&#125;
    G -->|"Yes"| GA["Argon2id. Target 2 GiB, t=1, p=4. Else 64 MiB, t=3, p=4. Never below 19 MiB, t=2, p=1"]
    G -->|"No"| H&#123;"Huge single-operator deployment that can run a multi-GB ROM?"&#125;
    H -->|"Yes"| HR["yescrypt plus a site ROM, to raise the attacker's capital cost"]
    H -->|"No"| M&#123;"Need memory-hardness but Argon2id is unavailable?"&#125;
    M -->|"Yes"| MS["scrypt, N of 2^17, r=8, p=1, about 128 MiB"]
    M -->|"No"| BC["bcrypt, work factor 10 or higher, 72-byte cap enforced"]
</Mermaid>

The tree points at a scheme; the table below pins the numbers. Every figure here is drawn from RFC 9106, the OWASP Password Storage Cheat Sheet, yescrypt's documentation, or NIST SP 800-63B-4 [@rfc9106; @owasp; @yescrypt; @nist-63b]. One trap to name explicitly: OWASP's iteration counts drift upward over time, so this table prints the values from the dated 2025-06-02 snapshot, not the live page -- if you copy a number, copy it from a dated source so your reviewer can reproduce it.

| Scheme | Parameters to start from (2024-2026) | Salt | Notes |
|---|---|---|---|
| Argon2id | RFC 9106 target `t=1, p=4, m=2 GiB`; memory-constrained `t=3, p=4, m=64 MiB`; OWASP floor `t=2, p=1, m=19 MiB` [@rfc9106; @owasp] | 128-bit | Pin the id variant; never ship bare Argon2i as the default |
| yescrypt | The distro default settings; add a multi-GB ROM at scale [@yescrypt] | library-managed | Linux `/etc/shadow`; `$y$` MCF |
| scrypt | `N` of `2^17`, `r=8`, `p=1` (about 128 MiB) [@owasp] | 128-bit | Or an equivalent point on the p-for-RAM curve |
| bcrypt | Work factor 10 or higher; enforce the 72-byte cap [@owasp] | 128-bit | Safe pre-hash only as `bcrypt(base64(hmac-sha384($pw, key=pepper)))` |
| PBKDF2 (FIPS lane) | HMAC-SHA256 600,000; HMAC-SHA512 210,000; HMAC-SHA1 1,300,000 (legacy) [@owasp] | 32 bits or more; use 16 bytes | These are the dated-snapshot figures; the live page has since drifted up |

### From a scheme name to a real API call

Naming the scheme is not the same as shipping it, and the gap between them is where a surprising amount of security is lost. Here is the bridge from each scheme to the concrete binding a protocol designer actually calls.

| Scheme | Where to call it, in real code |
|---|---|
| Argon2id | libsodium `crypto_pwhash`, the PHC reference library, `argon2-cffi` (Python), PHP `PASSWORD_ARGON2ID`, `golang.org/x/crypto/argon2`, Rust `argon2`, `node-argon2`, and .NET wrappers [@argon2-ref] |
| yescrypt | `libxcrypt` through `crypt(3)`, plus Openwall's reference code [@libxcrypt] |
| scrypt | OpenSSL, libsodium, Python `hashlib.scrypt`, Node `crypto.scrypt` [@rfc7914] |
| bcrypt | PHP `PASSWORD_BCRYPT`, Spring Security, Django, and `bcrypt` libraries for Ruby, Node, and Python [@cryptbf] |
| PBKDF2 | Every standard library: .NET `Rfc2898DeriveBytes`, Python `hashlib.pbkdf2_hmac`, Django's default hasher [@rfc8018] |

> **Note:** The load-bearing warning of this whole section: several Argon2 bindings default to the weaker Argon2i, or to under-powered memory and time settings. "We use Argon2" is not the claim you want -- "we use Argon2id at `t`, `m`, `p` we chose on purpose" is. Read your library's default constructor, pin the variant and every parameter explicitly, and write a test that fails if the stored cost drops below your target [@argon2-ref].

### The four things you always do

Whatever the tree selected, four habits are non-negotiable. NIST SP 800-63B-4 section 3.1.1.2 *requires* (SHALL) two of them -- salted and hashed storage with an approved one-way function, and verification of the *entire* password with no truncation -- and *recommends* (SHOULD) a third, a stored algorithm-and-cost reference so you can migrate; it also calls for throttling on repeated failures [@nist-63b].

First, generate a **16-byte per-password salt from a CSPRNG**. NIST's floor is a mere 32 bits; treat that as a floor to clear by a wide margin, not a target, and generate it the way [Part 2, "Predictable or Repeated,"](/blog/predictable-or-repeated-the-only-two-ways-cryptographic-rand) insists [@nist-63b]. Second, store the algorithm and its parameters in **MCF form**, so a verifier can dispatch on the scheme and upgrade in place. Third, compare verifiers in **constant time**, so you never leak how many bytes an attacker has guessed.

Fourth, **rehash on login**: when the stored cost sits below today's target, transparently recompute the verifier from the plaintext you just checked, and write the stronger value back. That last habit turns a parameter choice from a one-time decision into a living one -- and it is short enough to paste in.

<RunnableCode lang="js" title="The login handler pattern: constant-time verify, then rehash on login">{`
// Mock only -- this illustrates control flow, not real crypto.
// A real system calls its library's verify(), needsRehash(), and hash().

var TODAYS_TARGET_COST = 12; // e.g. a bcrypt work factor, or an Argon2 (m,t,p) tuple

// Constant-time comparison: never return early on the first differing byte,
// or the timing leaks how much of the verifier the attacker has matched.
function constantTimeEquals(a, b) {
  if (a.length !== b.length) return false;
  var diff = 0;
  for (var i = 0; i < a.length; i++) {
    diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
  }
  return diff === 0;
}

function mockHash(password, cost) {
  // Stand-in for bcrypt or Argon2id. Real code never rolls its own.
  return "cost=" + cost + ":" + password.split("").reverse().join("");
}
function costOf(verifier) {
  return parseInt(verifier.split(":")[0].split("=")[1], 10);
}

function login(password, stored, updateStore) {
  var candidate = mockHash(password, costOf(stored));
  if (!constantTimeEquals(candidate, stored)) {
    return "reject"; // and throttle repeated failures for this account
  }
  // Verified. If the stored cost is below today's target, upgrade transparently.
  if (costOf(stored) < TODAYS_TARGET_COST) {
    updateStore(mockHash(password, TODAYS_TARGET_COST));
    return "accept, and rehashed from cost " + costOf(stored) + " to " + TODAYS_TARGET_COST;
  }
  return "accept";
}

var stored = mockHash("correct horse battery staple", 10); // hashed years ago at cost 10
console.log(login("correct horse battery staple", stored, function (h) {
  console.log("  stored verifier upgraded ->", h.slice(0, 16) + "...");
}));
console.log(login("wrong password", stored, function () {}));
`}</RunnableCode>

### Peppering, and server-cost budgeting

Two operational topics finish the guide. The first is peppering -- powerful, and easy to get wrong in a way you cannot undo.

<Aside label="Peppering without shooting yourself in the foot">
A pepper is a *secret* mixed into the hash as defense-in-depth, and three things decide whether it helps or hurts. **One: keep the secret out of the database.** It belongs in an HSM, a KMS, or a TEE, never in the same store as the verifiers it is meant to protect -- a pepper sitting next to the hash is not a pepper, it is decoration. **Two: encrypt the hash under the pepper, do not HMAC it in.** Dropbox's design is the reference: `SHA512(pw)`, then bcrypt at cost 10 with a per-user salt, then `AES-256` encryption of that output under a global pepper held separately [@dropbox]. Because encryption is reversible with the key, you can *rotate* the pepper; an HMAC folded into the one-way step cannot be rotated without every user's plaintext, which you do not have. **Three: treat it as a bonus layer, never a substitute** for a real password hash. NIST SP 800-63B-4 recommends exactly this shape -- an additional keyed hashing or encryption operation with the key in a hardware-protected area [@nist-63b]. And if you pepper to work around bcrypt's 72-byte cap, the safe pre-hash is `bcrypt(base64(hmac-sha384($pw, key=pepper)))`: the keyed HMAC defeats password shucking, and the base64 encoding defeats the `$2a$` NUL-truncation bug [@owasp; @scottbrady-shucking].
</Aside>

The second is the budget the whole design lives inside. Memory multiplied by concurrency is the real constraint, not CPU: Argon2id at 1 GiB per hash across 200 simultaneous logins wants roughly 200 GiB of transient RAM. That is the concrete reason the deployed floors of 19 to 64 MiB sit so far below RFC 9106's 2 GiB ideal [@rfc9106; @owasp].

Target somewhere between half a second and one second per hash, measured on your own hardware. Rate-limit the login endpoint, because an attacker who floods it makes *you* pay the expensive hashing cost -- a denial-of-service lever you handed them. And where the traffic justifies it, put a cheap pre-filter in front of the expensive hash. The parameters are not a trophy you set once; they are a running lease on your own server budget.

<Spoiler kind="solution" label="Measure your own hash latency before you pick parameters">
The parameter tables above are starting points, not answers -- the only number that finally matters is how long a hash takes on *your* hardware. The Argon2 reference CLI measures it directly. This runs Argon2id at 64 MiB, three passes, and four lanes, and prints the elapsed time:

`echo -n "correct horse battery staple" | argon2 mysalt1234 -id -t 3 -m 16 -p 4`

Here `-id` selects the Argon2id variant and `-m 16` means `2^16` KiB, which is 64 MiB. Raise `-m` until the reported time approaches your latency budget -- roughly half a second to a second -- then hold there, and re-measure on the hardware you will actually deploy on, not your laptop [@argon2-ref].
</Spoiler>

The checklist is short because the lesson is one sentence. But checklists are not what fail in production -- confident, wrong sentences are, the kind that survive code review because they sound obviously true. Clear those before the conclusion, and the bug that has haunted this entire article finally has nowhere left to live.

## 11. FAQ and Common Misuse: The Confident, Wrong Sentences

Broken password storage survives mostly because a handful of plausible-sounding claims keep getting said in design meetings, and a handful of code patterns keep getting shipped past review. Named and corrected, they lose their power. Here is the consolidated catalog of what goes wrong in real code, each pattern mapped to the named breach it reproduces and the one change that fixes it.

| Misuse pattern in the code | The break it reproduces | The fix |
|---|---|---|
| A bare fast hash (MD5, SHA-1, SHA-256) as the password store | LinkedIn, RockYou | Use a deliberately slow, ideally memory-hard KDF [@troyhunt] |
| bcrypt silently truncating input at 72 bytes | Long passphrases lose entropy past byte 72 | Enforce the 72-byte cap explicitly, or pre-hash correctly [@cryptbf; @owasp] |
| Naive unsalted pre-hash `bcrypt(md5($pw))`, and the `$2a$` NUL bug | Password shucking | Salt or HMAC the inner step and base64-encode it [@scottbrady-shucking] |
| Argon2d (data-dependent) on shared or multi-tenant hosts | Cache-timing exposure of the access pattern | Prefer Argon2id, whose first half-pass is data-independent [@rfc9106] |
| A global salt, a reused salt, or no salt | LinkedIn: rainbow tables and cross-account hash sharing | A unique per-password CSPRNG salt, stored in the MCF string [@troyhunt] |
| A parallel weak code path that also touches the password | Ashley Madison's fast MD5 `$loginkey` beside strong bcrypt | Audit every function that sees the password, not just the verifier [@cynosure-am] |
| Encrypting the password instead of hashing it | Adobe's reversible 3DES in ECB mode | Hash, never encrypt, a stored password [@adobe-ars2013] |
| Storing the pepper next to the hash it protects | Defeats the entire point of a secret | Keep the pepper in a separate HSM, KMS, or TEE [@nist-63b] |
| Parameters chosen once and never raised | A 2019 cost is weaker every year after | Rehash on login and lift the floor as hardware improves |
| No maximum input length feeding PBKDF2 | Django's 2013 long-password DoS (CVE-2013-1443) | Cap the accepted input length; Django's fix rejected inputs over 4096 bytes [@django-cve] |
| Verifier comparison that short-circuits on the first byte | A timing side channel on the compare | Constant-time equality, plus throttling of failed attempts [@nist-63b] |
| "Memory-hardness is always mandatory" | Over-provisioned Argon2id dialed to a DoS-safe floor | Treat memory-hardness as the default, not a universal law [@yescrypt] |

The Django entry is worth one sentence, because it is the misuse people least expect. Django set no maximum on password length, so an attacker could submit a roughly one-megabyte password -- about a minute of PBKDF2 work -- purely to exhaust server CPU, a self-inflicted denial of service the fix closed by rejecting any input over 4096 bytes [@django-cve].

> **Note:** Your password security is the *weakest function that ever touches the password*. A strong bcrypt sitting beside a fast MD5 token (Ashley Madison), a plaintext line in a log file (Facebook), or a naive `bcrypt(md5($pw))` (shucking) erases every bit of the verifier's strength. Audit the whole path, not just the column labeled "password hash."

With the code patterns cleared, here are the sentences themselves -- the confident, wrong claims, and the short answers that retire them.

<FAQ title="Frequently asked questions">
<FAQItem question="SHA-256 is strong and one-way, so why is it not fine for passwords?">
Because its strength is not the property that matters, and its speed is the flaw. A password hash exists to make each guess expensive; SHA-256 is engineered to make each evaluation cheap, so one GPU tries tens of billions of guesses per second and a stolen dump falls to a wordlist in minutes [@hashcat-4090]. Use a deliberately slow, ideally memory-hard function -- Argon2id, scrypt, bcrypt, or PBKDF2 -- not a general-purpose hash.
</FAQItem>
<FAQItem question="Do I still need a salt if I use bcrypt or Argon2?">
They already salt for you: bcrypt and Argon2id generate a per-password salt and embed it in the MCF string. What you must never do is reuse a salt, use a single global salt, or omit it -- that is precisely the LinkedIn failure, where identical passwords collided and precomputed tables applied [@troyhunt]. The salt is public and stored beside the hash; it is not the secret, and it changes attacker throughput by zero.
</FAQItem>
<FAQItem question="Is bcrypt obsolete, and should everything move to Argon2?">
No. bcrypt (1999) and PBKDF2 (2000) were never superseded, only joined by the memory-hard generation. bcrypt at work factor 10 or higher is a defensible choice for many deployments -- yescrypt's own author says as much for smaller ones -- and a FIPS constraint forces PBKDF2 regardless [@yescrypt]. Memory-hardness is the recommended default, not a universal mandate.
</FAQItem>
<FAQItem question="What is a pepper, and do I need one?">
A pepper is a *secret*, server-held value -- kept in an HSM or KMS, separate from the database -- mixed into the hash so a stolen database alone cannot be cracked [@nist-63b]. It is defense-in-depth only, never a substitute for a real password hash. If you use one, *encrypt* the hash under it (rotatable, the design Dropbox shipped) rather than HMAC it into the one-way step (not rotatable), and never store it next to the hash [@dropbox].
</FAQItem>
<FAQItem question="Is it safe to pre-hash to get around bcrypt's 72-byte limit?">
Only if you do it the careful way. A naive `bcrypt(md5($pw))` with an unsalted inner hash enables password shucking, and a NUL byte in the pre-hash can truncate the input [@scottbrady-shucking]. Salt or HMAC the inner step and base64-encode it -- `bcrypt(base64(hmac-sha384($pw, key=pepper)))` -- or enforce the 72-byte cap and skip pre-hashing entirely.
</FAQItem>
<FAQItem question="Does quantum computing break password hashes?">
No. Grover's algorithm offers only a square-root speedup on guessing, which roughly halves effective bit-strength, and that gain is swamped by the memory-bandwidth and per-guess costs a good password hash already imposes. No Shor-style structural break applies to a one-way hash [@nist-ir-8105]. Your multi-factor and password-reuse posture matter far more to your real risk.
</FAQItem>
<FAQItem question="How do I choose parameters for my server?">
There is no closed-form answer, which is why this is still an open problem. Target roughly half a second to one second per hash, measured on your own hardware, within your RAM-times-peak-concurrency and denial-of-service budget. Start from RFC 9106's two configurations or OWASP's floors, and raise them over time with rehash-on-login as your hardware and your traffic allow [@rfc9106; @owasp].
</FAQItem>
</FAQ>

Every correction here points at the same root. The strength of the hash was never the variable that decided the outcome. The cost per guess -- on the attacker's cheapest hardware, measured against your own server's budget -- always was. That is the sentence the whole article was built to earn, so it is time to say it in full.

## One Job, Priced Two Ways

Return to the LinkedIn dump from the first page: a strong hash, no key to break, plaintext in days -- because the function was *fast*, and fast is the one thing a password hash must never be. Everything between here and there was the field learning, and relearning, that the hash's cryptographic strength was never the variable. The cost of one guess was.

Read as one arc, forty-seven years of password hashing is a single economic move, repeated. Morris and Thompson's 1979 rules -- one-way, salted, slow -- set the terms. Every advance since has restored the *same* eroding ratio: the cost of one attacker guess divided by the cost of one honest login.

Faster CPUs eroded fixed iteration counts, so bcrypt made the count a knob and added an expensive key schedule. Cheap parallel hardware -- GPUs, FPGAs, ASICs -- eroded time-hardness itself, so scrypt and Argon2 changed the *shape* of the cost from time to memory, the one resource an attacker cannot buy asymmetrically cheaply. Botnets and cheap RAM erode even that, so yescrypt adds a site-specific ROM. Iteration time, then an expensive key schedule, then memory, then memory plus a ROM: four levers, one idea.

The failure catalog proves the spine from the other side, because every entry is the ratio collapsing somewhere the verifier column could not see. Ashley Madison ran genuinely strong bcrypt and lost anyway, to a fast MD5 token stored beside it [@cynosure-am]. Facebook's at-rest hashing was undone by plaintext in a log file [@krebs-fb2019]. Adobe encrypted where it should have hashed [@adobe-ars2013]. Password shucking turned a careless pre-hash back into a fast-hash problem [@scottbrady-shucking]. In none of these did memory-hardness matter, because the weakest function that touched the password was never the one on the spec sheet.

So the resolution the field converged on is not a single winner. It is a priced menu: Argon2id and yescrypt where you can afford memory-hardness, bcrypt and PBKDF2 where you cannot or where a regulator forbids it -- each choice an economic decision bounded by your own server's budget, exactly as [Part 1's attacker model](/blog/secure-against-whom-the-security-definitions-every-protocol-) and [Part 10's "a fast hash is the wrong tool"](/blog/the-fingerprint-two-files-shared-a-field-guide-to-cryptograp) both predicted. Keep the one-question test where you can reach it.

<PullQuote>
On the cheapest hardware an attacker can rent, how much does one guess at one password cost -- and how far above your own per-login cost have you pushed it?
</PullQuote>

The hash was never the weak link, and no future hash will be, because the weak link is economic, not cryptographic. That is why this is Part 12 of a field guide to *protocol design* rather than a catalog of algorithms. The job was never to pick the strongest function. It was to price a guess correctly, on both sides of the ledger.

<StudyGuide slug="password-hashing-bcrypt-scrypt-argon2-yescrypt" keyTerms={[
  { term: "Stored verifier", definition: "A salted, deliberately slow, one-way transform of a password, stored so that a leaked database does not directly reveal the passwords." },
  { term: "Offline attack", definition: "The threat model this primitive answers: the adversary holds the stolen verifiers and guesses locally at full hardware speed, with no server or rate limit in the loop." },
  { term: "Salt", definition: "A public per-password random value that makes each verifier unique and defeats precomputed tables; it does not change attacker throughput." },
  { term: "Modular Crypt Format (MCF)", definition: "A self-describing hash string, id then params then salt then hash, that lets one column hold many algorithms and migrate in place." },
  { term: "Work factor (adaptive cost)", definition: "A tunable knob -- bcrypt cost, PBKDF2 iterations, Argon2 time and memory -- that raises per-guess cost as hardware improves, without changing the algorithm." },
  { term: "Memory-hard function (MHF)", definition: "A function whose cheap computation requires holding a large array in memory for much of its runtime, imposing an area-times-time cost." },
  { term: "AT-cost (area times time)", definition: "Memory occupied multiplied by time held: the attacker's real per-guess cost, and the quantity the memory-hard program maximizes." },
  { term: "Data-independent vs data-dependent access", definition: "Whether memory addresses depend on the password; independent is cache-timing-safe but provably suboptimal, dependent is optimal but leaks through timing." },
  { term: "Pepper (secret salt)", definition: "A secret, server-held value kept in an HSM or KMS, separate from the database, mixed into the hash as defense-in-depth only." }
]} />
