A password is the worst secret in the system, because a person chose it and has to remember it.
The previous lesson established that the server needs a secret on every request and that hashing is one way. This lesson is about the only secret the user supplies directly. If you have arrived here on your own, what matters from earlier is that a hash is a fast one-way digest with no key, and that "one way" only means anything when the input is unpredictable. A password is exactly the case where it is not.
Why a fast hash loses, with numbers
Store sha256(password) and you have not hidden the password. You have written down a value that an attacker with your database can test candidates against, as fast as their hardware runs SHA-256.
The rate is the whole argument, so use a real one. Published hashcat benchmarks for a single consumer graphics card, an RTX 4090, report roughly 21.9 billion SHA-256 hashes per second. Now take a password nobody would call weak on sight: eight lowercase letters, no dictionary word, something like qjfbmzrx. The search space is candidates. Divide:
Nine and a half seconds, on one card, for every eight-letter lowercase password in existence. Allow the full 95 printable ASCII characters at eight long and it is candidates, about 3.5 days. Those are the numbers for one card bought at retail, and real cracking does not begin with brute force at all: it begins with the previous breach corpora, then dictionary words with the substitutions people actually make, and most of a stolen file falls in the first few minutes.
So a fast hash is not a defence, it is a formality. The fix is to make each single guess expensive, and the term for it is stretching: a function deliberately built to be slow, tuned so that one evaluation costs the server a fraction of a second and therefore costs the attacker the same fraction of a second, multiplied by the size of their search.
Take bcrypt at work factor 12. The same benchmark reports about 184,300 bcrypt hashes per second at work factor 5, and each increment of the factor doubles the work, so factor 12 is times slower, giving about 1,440 hashes per second. Run the eight-lowercase space against that:
From 9.5 seconds to 4.6 years, on the same hardware, for the same password. That factor of about fifteen million is what stretching buys, and it is the entire content of password storage.
Example. Your database uses sha256(password) and you are asked how long it would take an attacker to crack an account whose password is ten lowercase letters. Then say what the same password costs under bcrypt at work factor 12.
The space is . At hashes per second that is seconds, or 1.8 hours, on one card. At bcrypt's 1,440 hashes per second the same space needs seconds, which is about 3,100 years. The password did not change. Only the cost per guess did.
Now you. An attacker rents eight cards instead of one and attacks a bcrypt work factor 12 hash of an eight-lowercase-letter password. How long, and what does that tell you about work factor as a defence?
Answer
Eight cards give hashes per second, so seconds, about 0.58 years or seven months. Hardware scales linearly and the work factor scales exponentially, which is the good news: one increment of the factor, costing your server twice as long per login, cancels a doubling of the attacker's budget. The bad news is that the user's choice scales fastest of all. Two more lowercase letters multiply the attacker's work by 676, which no realistic parameter change can match.
Salt, and what it does not do
A salt is a unique random value stored alongside each hash and mixed into it. Sixteen bytes from crypto.getRandomValues is standard, and it is not secret: the modern password hashes encode it in plain text inside the stored string, right next to the digest.
What salting stops is amortisation. Without a salt, every user with the password hunter2 has the same stored digest, so cracking one cracks all of them, and a table of common passwords precomputed once matches against every breached database forever. With per-user salts the same password gives different digests for different users, the table is worthless, and the attacker must run the full expensive computation separately for each account.
What salting does not do is protect any individual password. Guessing hunter2 against one salted hash costs one hash evaluation, exactly as before. Salts change the economics across a file, not the strength of a single entry. People sometimes conclude from "the salt sits in plain text next to the hash" that salting is pointless, and the conclusion is backwards: the salt was never meant to be secret, it was meant to make each account a separate problem.
The two ways to get it wrong are to reuse one salt for the whole application, which recreates the precomputation attack in full, and to derive the salt from the email address, which does the same across sites, since [email protected] has the same derived salt everywhere.
Slow on purpose: three dials
A password hash is tuned with up to three parameters, and knowing what each defends against is what lets you set them.
Time, meaning iterations or passes. Raising it multiplies the work for you and for the attacker in the same proportion. This is the only dial PBKDF2 has, and it is why PBKDF2 is the weakest of the acceptable functions: an attacker's card runs thousands of SHA-256 cores in parallel, so raising an iteration count that costs your one CPU core dearly costs their thousands of cores proportionally much less.
Memory. This dial changes the shape of the fight. If each evaluation needs 19 MiB of working memory, read and written throughout, an attacker cannot run ten thousand instances in parallel on a card holding 24 GB: is about 1,230 instances, and that ceiling comes from memory rather than cores. Memory hardness is what makes custom cracking hardware unattractive, because memory is the one component a custom chip cannot make cheaper.
Parallelism, meaning how many lanes one evaluation may use, lets you spend more CPU per hash without more wall-clock time. Leave it at 1 unless you have measured a reason.
Pick the parameters from a measured time budget on your own hardware, never from a number copied out of a blog post written for someone else's. The budget comes from your login rate: if a hash takes 250 ms and the process has 8 cores free for hashing, the ceiling is logins per second, and a burst above that queues. Choose the largest cost whose ceiling still clears your realistic peak, then re-measure once a year, because the hardware moves.
Note what this implies. Password hashing is the one place in a web application where you deliberately make yourself slow, so it is also a denial-of-service surface: an attacker firing a thousand attempts per second at a 250 ms hash consumes 250 seconds of CPU per second of attack. Rate limiting is not an optional companion to expensive hashing, it is what makes expensive hashing safe to deploy.
Choosing the function
Four functions are in serious use, and the ranking is not controversial.
Argon2id won the Password Hashing Competition in 2015 and is the default recommendation. It is memory hard, it resists both side-channel attacks and time-memory tradeoffs, and it exposes all three dials. The OWASP Password Storage Cheat Sheet gives 19 MiB of memory, 2 iterations and 1 degree of parallelism as a baseline, with equivalent trades such as 46 MiB with 1 iteration. Use it for a new system.
scrypt is older, also memory hard, and a fine second choice. Its parameters are less pleasant to reason about, since the cost factor N, block size r and parallelisation p interact.
bcrypt dates from 1999 and is not memory hard in the modern sense, though its 4 KiB of working state still costs an attacker something. It is fine for existing systems, with one trap: bcrypt reads at most 72 bytes of input and silently ignores the rest, so a long passphrase is truncated. The tempting fix of hashing with SHA-256 first has its own problem, since a raw digest may contain a zero byte that terminates the string in some implementations. If you pre-hash, base64 the digest first.
PBKDF2 is the one Web Crypto actually implements, and that convenience is its only real advantage. With no memory hardness it is the cheapest of the four for an attacker per unit of pain inflicted on you. OWASP puts the recommendation at 600,000 iterations with HMAC-SHA-256. Work out what that buys: a card at raw SHA-256 hashes per second, spending roughly two compressions per iteration, manages about password guesses per second, thirteen times faster than bcrypt at work factor 12. Against our eight-lowercase password, seconds, about 132 days. Acceptable, and clearly the weakest of the four.
This is the one place where the course leaves Web Crypto, because Web Crypto has none of the top three. Reach for a WebAssembly implementation such as hash-wasm, which provides Argon2id and runs in browsers, Bun and Node alike.
import { argon2id } from "hash-wasm";
const salt = new Uint8Array(16);
crypto.getRandomValues(salt);
const stored = await argon2id({
password: "correct horse battery staple",
salt,
parallelism: 1,
iterations: 2,
memorySize: 19456, // KiB, so 19 MiB
hashLength: 32,
outputType: "encoded"
});
// $argon2id$v=19$m=19456,t=2,p=1$<salt>$<digest>The encoded output is the point of the format. Salt, parameters and digest travel in one string, so verification reads the parameters out of the stored value rather than assuming today's settings, and raising the cost next year does not invalidate every existing password.
Example. You measure Argon2id at 19 MiB and 2 iterations on production hardware at 310 ms per hash. You have 4 cores free for hashing and expect peaks of 20 logins per second. Do the parameters fit?
The ceiling is logins per second, below the 20 you expect, so at peak the queue grows without bound and logins time out. Halving the memory to 9.5 MiB roughly halves the time to 155 ms, giving per second, which clears 20 with little margin. Adding four cores at the original parameters gives the same 25.8 and keeps the stronger hash. The honest answer is that a sustained 20 per second needs more than four cores if you want real headroom.
Now you. A colleague proposes Argon2id at 1 GiB of memory, arguing that more is always better. What breaks?
Answer
Throughput and memory both. Each concurrent hash holds a gibibyte, so ten simultaneous logins need 10 GiB resident, and a 16 GiB server starts swapping or is killed by the kernel. Time per hash rises into seconds, collapsing the login ceiling to low single digits. Worse, it hands an attacker a cheap denial of service: a few hundred concurrent attempts exhaust the machine and nobody gets in. Parameters are chosen against your own capacity, and a value your server cannot serve is not a stronger setting, it is an outage.
Pepper, and its narrow value
A pepper is a secret mixed into every password hash and stored outside the database, in an environment variable, a key management service or a hardware module. The usual construction computes the password hash normally and then takes an HMAC of the result under the pepper key, which keeps the pepper away from the slow function and makes it rotatable.
Its value is narrow and real: it defends against exactly one scenario, the attacker who obtains the database and not the application secrets. A leaked backup, a read-only SQL injection, a misconfigured replica. In that case the stolen hashes are uncrackable, because guessing requires a key the attacker does not have. Its value is nothing at all against a full compromise of the application server, since whatever the application can read to verify a password, an attacker who owns the application can also read.
Add a pepper when you have somewhere genuinely separate to keep it, and plan rotation before you add it: rotating requires either keeping old key versions for verification or re-peppering on next login. Never let a pepper substitute for correct parameters on the underlying hash.
What the standards actually say
NIST Special Publication 800-63B, in the revision that landed in 2017 and in everything since, reversed a generation of received wisdom, and the reversal is still not universally applied.
No composition rules. Do not require an uppercase letter, a digit and a symbol. Users answer these rules with the same few transformations, Password1! and Summer2024!, which attack tooling models directly, so the rules shrink the space attackers actually explore while making passwords harder to remember.
No periodic expiry. A forced change every 90 days produces a pattern with an incrementing counter, and does nothing when a password is genuinely compromised. Force a change on evidence, not on a calendar.
Minimum eight characters, and accept at least 64. Length is the one thing a user supplies that genuinely helps, so truncating or rejecting long input is a bug. Accept all printable characters including spaces, accept Unicode, and normalise it consistently before hashing. Allow paste and a reveal toggle, since blocking paste breaks password managers, which are the most effective improvement available to an ordinary user.
Check the chosen password against a list of known-compromised values, which is the rule that replaces composition rules and is the subject of the next section.
The reasoning behind the reversal generalises. Rules that assume an attacker guesses uniformly at random are aimed at the wrong adversary: real attackers guess in order of likelihood, from corpora of what people have actually chosen. A password's strength is not the size of the space of strings that shape, but how far down the attacker's ordered list it sits. Tr0ub4dor&3 satisfies every composition rule and sits early on that list.
Checking against a breach corpus without sending the password
The rule to check new passwords against known-breached values raises an obvious objection: you are not sending your users' passwords to a third party. The k-anonymity protocol used by Have I Been Pwned's Pwned Passwords service solves this, and it is a genuinely elegant piece of design worth understanding rather than importing.
Hash the candidate with SHA-1, which is a deliberate choice of a fast hash because it is a lookup key rather than a stored secret. Take the first five hexadecimal characters. Send only those. The service returns every suffix it holds beginning with that prefix, and you search the list locally.
const digest = await crypto.subtle.digest("SHA-1", new TextEncoder().encode(password));
const hex = [...new Uint8Array(digest)]
.map(b => b.toString(16).padStart(2, "0")).join("").toUpperCase();
const res = await fetch(`https://api.pwnedpasswords.com/range/${hex.slice(0, 5)}`);
const body = await res.text();
const hit = body.split("\n").find(line => line.startsWith(hex.slice(5)));
const breached = Boolean(hit);For password, the SHA-1 digest is 5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8, so the request is for prefix 5BAA6 and the local search is for the suffix 1E4C9B93F3F0682250B6CF8331B7EE68FD8. The service learns the prefix and nothing else. Five hexadecimal characters give possible prefixes, and a corpus of around 850 million entries spreads to roughly 810 suffixes per prefix, so the returned list says nothing about which entry you wanted, or whether you were asking about a breached password at all.
Two honest limits: the protocol hides which password you asked about, not that a check happened, and a padded response option exists precisely because response length would otherwise leak a little. Against the benefit, which is refusing the passwords credential stuffing actually uses, the remaining exposure is negligible for most applications.
Example. A user tries to set their password to hunter2. Its SHA-1 digest is F3BBBD66A63D4BF1747940578EC3D0103530E21D. What exactly leaves your server, and what could the service infer?
The request is GET /range/F3BBB and nothing else: no password, no username, no full digest. The service learns that somebody asked about a password whose SHA-1 begins F3BBB, which is one of 1,048,576 prefixes and matches roughly 810 corpus entries plus an unbounded number of passwords not in the corpus at all. It cannot tell which, and it cannot tell whether the answer was a hit. Your server does the matching on the remaining 35 hexadecimal characters locally.
Now you. Why is SHA-1, a hash considered broken for signatures, the right choice here, and what would go wrong if the service used Argon2id instead?
Answer
SHA-1 is broken for collision resistance, which matters when an attacker gets to choose two colliding messages, and is irrelevant here: the digest is a lookup key into a public list, not a stored credential or a signature. Argon2id would be actively wrong for two reasons. It is salted, so the same password gives different outputs and no shared lookup key exists at all. And it is slow by construction, so checking a password against a corpus of 850 million entries would take years rather than milliseconds. The property needed is a fast, deterministic, unsalted key, which is exactly what makes SHA-1 unsuitable for storage and suitable for this.
The login flow itself
Verification is the mirror of storage, with three details that are routinely missed.
const user = await db.userByEmail(email);
const stored = user?.passwordHash ?? DUMMY_HASH;
const ok = await argon2Verify({ password, hash: stored });
if (!user || !ok) return new Response("Invalid email or password", { status: 401 });Always do the work. With no user for that email, the tempting early return answers in a millisecond instead of 310, and an attacker separates registered from unregistered addresses by timing alone. Verifying against a fixed dummy hash with the same parameters keeps both paths equally slow. The dummy is a real Argon2id hash of a random string, made once at startup.
One message for both failures. "No account with that email" and "wrong password" answer the question the attacker is actually asking, which is whether an address is worth attacking. Say Invalid email or password for both, with the same status. This is user enumeration, it recurs in sign-up, reset and verification, and the account lifecycle lesson deals with the parts a single error message cannot fix.
Upgrade on the way through. You hold the plaintext exactly once, at successful login, which is the moment to check whether the stored hash used older parameters and, if so, recompute and save it with today's settings. Since the encoded string carries the parameters that produced it, this is a comparison of stored against current, and it is how a system moves from bcrypt to Argon2id without asking anyone to reset anything.
Notice what verification does not accomplish. The user proved knowledge of the password at one request. The next arrives with nothing, and asking again on every page would mean the browser holds the password permanently and sends it constantly, multiplying the chances of it reaching a log, a proxy or an error report.
What the server needs is a secret that stands in for the password: one it can mint after a successful check, hand to the browser, recognise cheaply, and destroy at will. That is a session, and it is next.