Passwords, Properly
A password is the one secret the service stores on the buyer's behalf and must be able to verify without being able to read. Stagedoor's users.password_hash column held unsalted SHA-256 for its first two years, which is not that: a single GPU tries over a billion SHA-256 guesses a second, and with no salt, every buyer who chose Summer2024! shares one hash, so cracking it once opens all of them. The security review put it first on the list, above the JWT library that accepted alg: none, because a leaked table of fast hashes is a leaked table of passwords with a short delay.
The answer has three parts, and each one closes a different hole. Argon2id with a per-user salt, tuned so that one verification costs about 100 milliseconds on api-01, makes a guess expensive for the attacker in the same currency it is expensive for the service. A pepper held outside the database makes a leaked table uncrackable on its own. And the login and reset endpoints around the hash need a rate limit, a single error message, and a constant-time path, because the strongest hash in the world does not help if the endpoint answers "no such user" in 2 milliseconds and "wrong password" in 100.
Why a Hash, and Why Not a Fast One
The service never needs the password back. It needs to answer one question at login: is the string the buyer just typed the same as the one she chose? A one-way function answers that without keeping the original: store hash(password), and at login compute the hash of what arrived and compare. If the table leaks, the attacker holds hashes, not passwords, and has to guess: pick a candidate, hash it, compare, repeat. The entire security of the scheme is the cost of that loop, multiplied by the number of guesses the attacker needs.
SHA-256 was designed to be fast, because it checksums files and signs certificates, and fast is exactly wrong here. At a billion guesses a second, every 8-character password made of lowercase letters and digits (2.8 trillion of them) falls in under an hour, and the common ones fall in the first second, because the attacker tries a wordlist before the alphabet. A password hash must be slow on purpose: 100 milliseconds per guess turns a billion a second into ten a second per core, and the same alphabet takes 9,000 years. It must also be tunable, so that when hardware gets 4 times faster the cost is raised in one config value rather than a new algorithm.
Argon2id and Its Parameters
Slow on a CPU is not enough, because the attacker does not use a CPU. A GPU runs thousands of hash computations in parallel, and a hash that is merely slow is slow thousands of times at once. Argon2 is memory-hard: each computation needs a block of memory, 64 MiB in Stagedoor's setting, and a GPU with 24 GiB of memory can run 384 of those at once rather than 10,000. The cost the defender pays once per login is the cost the attacker pays per guess, and memory is the resource that does not get cheaper in parallel. Argon2id is the variant that mixes the data-independent and data-dependent modes, resisting both side-channel and time-memory tradeoff attacks, and it is the one RFC 9106 recommends for password storage.
Three knobs: memory, iterations (the RFC calls them passes), and parallelism (lanes). RFC 9106 gives two recommended settings; the second, 64 MiB of memory, 3 passes, 4 lanes, is the one for a service that cannot spare 2 GiB per login, and it is what argon2-cffi ships as its default. On api-01 that costs 95 to 110 milliseconds per hash, which is the target: long enough to hurt an attacker, short enough that a login is one round trip and not a spinner. The number to tune is the memory first and the passes second; the parallelism matches the cores the hash may use and is 4 on both instances.
from argon2 import PasswordHasher from argon2.exceptions import VerifyMismatchError hasher = PasswordHasher(memory_cost=65536, time_cost=3, parallelism=4) # RFC 9106, 2nd setting: ~100 ms def hash_password(plain: str) -> str: return hasher.hash(plain) # '$argon2id$v=19$m=65536,t=3,p=4$<16-byte salt>$<32-byte hash>' def verify_password(stored: str, plain: str) -> bool: try: hasher.verify(stored, plain) # reads m, t, p and the salt from `stored` except VerifyMismatchError: return False return True
Two functions and one hasher built with the three parameters. The stored string is not a bare hash: it begins with the algorithm name and version, then the memory, passes and lanes, then the salt, then the hash itself, separated by dollar signs. The verifier reads the parameters from the string it is checking, not from the hasher's current setting, which is what makes the setting safe to raise: a hash written last year with 3 passes still verifies after the config says 4, because the string says 3. No migration, no batch job, no downtime to change the cost. The salt is generated by the library, 16 random bytes per call, and it travels in the same string.
Salt and Pepper
The salt is random, per user, and public in the sense that it sits beside the hash in the same column. Its job is to make identical passwords produce different hashes, so an attacker with the table cannot hash Summer2024! once and look for matches, and cannot bring a precomputed table of hashes for the top 10 million passwords, because every entry would have to be computed 40,000 times, once per salt. It does not make a single hash harder to guess; that is the function's job. A salt on a fast hash is a fast hash that has to be attacked one row at a time, which at a billion a second is not a comfort.
The pepper is one secret, the same for every user, and it lives where the table does not: in the secret store of Chapter 11, loaded into the process at startup, never in Postgres. RFC 9106 gives argon2 a secret input for exactly this use, and where the library's high-level hasher does not expose it, the password is HMAC-SHA256'd with the pepper first and the result is what argon2 hashes. The consequence is that a dump of the users table, on its own, cannot be attacked at all: every guess needs the pepper, and the pepper was not in the dump. An attacker needs both the database and the running process's secrets, which is two breaches instead of one. The pepper cannot be rotated without a rehash on login, which is the last section's mechanism, so Stagedoor's is versioned: the hash string records which pepper it used.
The Login Endpoint
POST /login is the one route in Stagedoor that does 100 milliseconds of CPU on purpose, and that makes it the cheapest denial of service in the codebase if nothing limits it. At 1,000 attempts a second the hash alone is 100 CPU-seconds per second, every core on both instances, and the on-sale traffic behind it gets nothing. The rate limiter of Chapter 14 sits outside the auth ring, as Chapter 4 ordered it, and it limits login twice: 10 attempts per minute per source address, and 20 per hour per account, whichever trips first. The first stops one machine trying many passwords; the second stops many machines trying one account, which is what a credential-stuffing run with a botnet looks like.
The error is the same for an unknown email and a wrong password: one status, one Problem Details body, the "invalid email or password" of Chapter 3. The response time must match too. The obvious implementation loads the user, returns early if there is none, and otherwise runs the 100-millisecond verification, which answers "does this email exist" in the timing: 2 milliseconds for no, 100 for yes. Stagedoor's login handler runs the verifier in both cases, against the real hash when the user exists and against a fixed dummy hash when she does not, and then decides. The unknown-email path costs the service 100 milliseconds it did not strictly need, and it buys an endpoint that reveals nothing an attacker can measure.
Reset Without Leaking
The reset form is the enumeration oracle again, one layer over. "We could not find an account for that email" is a directory lookup at one request per address. The response is the same sentence either way, "if that email has an account, we have sent a link," with the same status and the same time, and the email itself is sent by a job through the outbox of Chapter 8 so that the response does not wait on the mail provider and the timing does not depend on whether a message was queued.
The token in that link is 256 random bits, valid for 15 minutes, single-use. The database stores its SHA-256, not the token, in a password_resets row with the user id and an expiry; SHA-256 is right here because the input is 256 random bits and not a password, which is the same reasoning Topic 28 applies to API keys. A stolen table of hashed reset tokens is worthless; a stolen table of plaintext ones is an account takeover for every buyer with a reset pending. The row is deleted when the token is used, when it expires, and when the password changes by any other route, so a reset link requested by an attacker before the buyer changed her password cannot be used after.
Rehash on Login
Hardware improves, the recommended cost goes up, and the table holds 40,000 hashes computed at the old cost. The plaintext is not available to recompute them, and it never should be. The upgrade path is the login itself: the verifier reads the parameters from the stored string, verifies with those, and if they are below the hasher's current setting, hashes the password that just arrived with the new setting and writes the new string over the old. argon2-cffi calls the check check_needs_rehash, and Django's hasher chain does the same thing under the name of the upgrade. The table upgrades itself one login at a time; buyers who never log in keep the old cost, which is fine, because a hash nobody uses is not the one under attack.
The same mechanism carries an algorithm change. A bcrypt string begins $2b$ and an argon2id string begins $argon2id$, so the verifier dispatches on the prefix, verifies with whichever it finds, and rewrites in the current one. Stagedoor's migration off SHA-256 was exactly this, with the one wrinkle that an unsalted SHA-256 verifier had to exist for a year to keep old accounts working; it was removed when the last active account had logged in once, and the 1,800 accounts that never did were forced through the reset flow.
bcrypt is CPU-hard with one cost knob, has a 72-byte input limit that silently truncates longer passwords, and has over 25 years of scrutiny behind it. Still acceptable; choose it where the library ecosystem has nothing else mature, and pre-hash nothing.
scrypt is memory-hard and older than argon2, with a cost knob that couples memory and time. Fine where it is already deployed; there is no reason to migrate to it.
Argon2id is memory-hard with three independent knobs, is a variant of Argon2, which won the Password Hashing Competition in 2015, and is the recommendation of RFC 9106. The default for anything new. Any of the three tuned to about 100 milliseconds is fine; SHA-256, MD5 and anything else designed to be fast is not, salt or no salt.
- SHA-256 with a salt — the salt defeats the precomputed table and nothing else; each row is still attacked at a billion guesses a second, and the 8-character passwords fall in an hour.
- A global salt — one salt for every user is a pepper without a secret; identical passwords still produce identical hashes, and the attacker computes one table for the whole site.
- No rate limit on login — the 100-millisecond hash becomes a CPU denial of service at 1,000 attempts a second, and the credential-stuffing run against 40,000 accounts costs the attacker nothing.
- Different errors for unknown email and wrong password — the login form is a user directory at one request per address, and a 2-millisecond "no such user" next to a 100-millisecond "wrong password" is the same directory with the message removed.
- A reset token stored in plaintext — the database leak becomes an account takeover for every buyer with a reset pending, when hashing the token would have made the stolen rows worthless.
- Raising the argon2 cost with a batch migration — there is no plaintext to rehash with, so the batch cannot run; the parameters live in the hash string precisely so that the upgrade happens at login.
- Hash with argon2id at the RFC 9106 second setting (64 MiB, 3 passes, 4 lanes), tuned to about 100 milliseconds on production hardware, and raise the cost as hardware improves with the rehash-on-login path carrying the table forward.
- Load a pepper from the secret store at startup and apply it before argon2, so a dump of the users table alone cannot be attacked; never store it in the database.
- Rate-limit login per source address and per account, keep the error body identical for both failures, and run the verifier against a dummy hash when the email is unknown so the timing matches.
- Issue reset tokens of 256 random bits, valid 15 minutes, single-use, stored as SHA-256, delivered by a job, and delete every pending reset when the password changes.
- Verify with the parameters stored in the hash string and rehash on login whenever they are below the current setting; never plan a batch rehash.
Knowledge Check
Marek adds a random 16-byte salt per user but keeps SHA-256 as the hash. What has changed for an attacker who steals the table?
- Precomputed tables stop applying, but each row still falls at a billion guesses a second
- The table is now uncrackable, because the attacker does not know which salt goes with which row
- Each guess now costs the attacker about 100 milliseconds, the same as it costs the service
- The attacker can no longer use a GPU, because salted hashes cannot be computed in parallel
What does the 64 MiB memory parameter of argon2id defend against that a slow CPU-only hash does not?
- A precomputed rainbow table of common passwords
- Thousands of guesses running in parallel on one GPU
- A timing gap between unknown emails and wrong passwords
- A leaked table attacked without the process's secrets
The reset endpoint answers "if that email has an account, we have sent a link" whether or not the account exists. What is the same rule applied to timing?
- Add a fixed 100-millisecond sleep before each reset response, so both cases take as long
- Send the email inline before responding, so the time reflects real delivery either way
- Queue the email through a job in both cases, so the response never waits on the mail provider
- Rate-limit the reset form per address, so an attacker can only measure a few responses a minute
Stagedoor raises argon2's passes from 3 to 4. How do the 40,000 existing hashes get upgraded?
- A nightly job recomputes every row with the new cost from the stored hashes
- Every buyer is forced through the reset flow, because the old hashes stop verifying
- The config change is blocked until a migration rewrites the strings with the new parameters
- Each row is rewritten at its owner's next successful login, using the password that just arrived
You got correct