The previous two chapters were about losing a domain without anyone touching a password. This one is about the opposite failure: the password (and the session behind it) is exactly what the attacker is after, and they almost never get it by "hacking" in the movie sense. They get it because the login system did something subtly, ordinarily wrong — stored a secret it should have stretched, trusted a cookie it should have scoped, or answered a question it should have refused.
Authentication is the single most attacked surface on the web, and it's the one most likely to be hand-rolled by someone learning. That's a dangerous combination. So this chapter is a guided tour of how logins actually get broken — credential stuffing, session hijacking, fixation, CSRF, OAuth slips, reset-flow leaks — and, for each, the defense that holds. The running example is the exact auth you built by hand in Web Ch 11 — Sessions, Cookies, and JWT and Ch 12 — Google OAuth, running on a Cloudflare Worker. There we built it to work. Here we make it hold.
The map: every place a login can leak
Before the details, hold the whole surface in your head. A login system has four phases, and each one has its own way to fail.
Figure 1 — The login attack surface. Notice the pattern: none of these are exotic. Each is the default behavior of code written to merely work, and each has a named, well-understood defense. Security here is mostly about not shipping the default.
We'll walk them in order.
Phase 1 — Storage: why a leaked database is not automatically game over
Assume the worst has already happened: your users table is dumped — every row, every column. Whether that's a catastrophe or a shrug depends entirely on what's in the password column.
- Plaintext. Every account is compromised the instant the dump lands, and because people reuse passwords, you've just handed the attacker a key ring for other sites too. Unforgivable in 2026, still found regularly.
- Fast hash (MD5, SHA-256, unsalted). Better than nothing, almost worthless in practice. A modern GPU computes billions of SHA-256 hashes per second. The attacker hashes a wordlist of common passwords once and matches the whole column in minutes. Identical passwords produce identical hashes, so a single rainbow table cracks thousands of rows at once.
- Salted slow hash (the right answer). Each password is run through a deliberately slow, memory-hard function with a unique random salt per user. The salt kills rainbow tables (every row must be attacked individually) and the slowness kills throughput (the attacker gets thousands of guesses per second, not billions).
The whole game of password storage is asymmetry: you verify a password exactly once per login, so you can afford 100 ms of work; the attacker must verify billions of guesses, so 100 ms each is ruinous.
The right functions, and the one the Workers runtime gives you
The accepted choices, best first: Argon2id, scrypt, bcrypt, and — when those aren't available — PBKDF2 with a high iteration count. The first three are memory-hard: they force the attacker to spend RAM per guess, which neutralizes cheap GPU parallelism. PBKDF2 is only CPU-hard, so it needs a large iteration count to compensate.
On a Cloudflare Worker the Web Crypto API gives you PBKDF2 out of the box, which is why the Ch 11 build used it. That's an acceptable floor — if you turn the iteration count up (six figures, and revisit it yearly as hardware speeds up) and pair it with the rate limiting and breached-password checks below. If you can run a WASM build of Argon2id at your auth layer, prefer it.
// PBKDF2 via Web Crypto on a Worker — the Ch 11 pattern, security-annotated.
// The cost knobs that matter for an attacker are SALT (unique per user) and
// iterations (make a single guess expensive). Bump iterations over time.
const ITERATIONS = 210_000; // a 2026 floor for PBKDF2-SHA256; raise, never lower
async function hashPassword(password) {
const salt = crypto.getRandomValues(new Uint8Array(16)); // unique, per user
const keyMaterial = await crypto.subtle.importKey(
"raw", new TextEncoder().encode(password), "PBKDF2", false, ["deriveBits"],
);
const bits = await crypto.subtle.deriveBits(
{ name: "PBKDF2", salt, iterations: ITERATIONS, hash: "SHA-256" },
keyMaterial, 256,
);
// Store salt + iterations + digest together; you need them all to verify.
return `pbkdf2$${ITERATIONS}$${b64(salt)}$${b64(new Uint8Array(bits))}`;
}Mini-exercise: feel the asymmetry
Hash the string "password123" with plain SHA-256 in your browser console (crypto.subtle.digest) and note how instant it is. Now picture a leaked column of 50,000 such hashes. A wordlist of the 10,000 most common passwords, hashed once, matches every reused password in that column in well under a second — and because there's no salt, duplicates light up together. That instant feeling is exactly the attacker's advantage you're trying to destroy with a slow, salted hash. Write down: what makes my hash slow, and what makes it unique per user? If you can't answer both, your storage isn't done.
Phase 2 — Submission: stuffing, brute force, and enumeration
Storage protects you after a leak. But most account takeovers never touch your database — they come straight through the front door, the login endpoint, using passwords the attacker already has.
Credential stuffing — someone else's breach, your incident
Billions of email:password pairs from other companies' breaches are traded freely. Credential stuffing is the brute-force-with-a-cheat-sheet attack: a bot replays those known pairs against your login, betting (correctly, ~0.1–2% of the time) that people reuse passwords. It's the number-one cause of account takeover on the web, and your own security is irrelevant to it — the password was strong enough; it just wasn't secret anymore.
Figure 2 — Credential stuffing. The passwords are real and the requests look like legitimate logins, which is exactly what makes naive defenses (block "wrong passwords") useless — these aren't wrong passwords, they're wrong owners.
Defenses, layered:
- Rate-limit the account, not just the IP. Stuffing bots rotate through thousands of IPs, so per-IP limits barely dent them. Limit failed attempts per account (per email) over a sliding window, and add a global per-IP ceiling on top. On Cloudflare this is a natural fit for a Worker + KV or Durable Object counter, or the platform Rate Limiting rules.
- Check submitted passwords against breach corpora. At signup and password change, reject passwords known to be compromised. The privacy-preserving way is k-anonymity: hash the password (SHA-1), send only the first 5 hex characters of the digest to a breach API (Have I Been Pwned's range endpoint), and check the returned suffix list locally. The full password never leaves your server.
- Add a second factor (see Phase 3's TOTP). A stuffed password is useless without the user's authenticator.
- Watch for the signature. A spike of logins that are individually valid-looking but collectively anomalous (one IP block, many accounts; or many IPs, one account) is the stuffing fingerprint. Alert on it.
Account enumeration — the leak before the breach
Watch what your login and reset endpoints say. If "wrong password" and "no such account" produce different responses — different text, different status code, or even a different response time because one path runs the slow hash and the other returns instantly — an attacker can probe your endpoint to learn which emails have accounts. That list is the input to a targeted stuffing run, and on its own it can be a privacy harm (proving someone has an account on a sensitive service).
The fixes are uniformity:
- Identical responses for "wrong password" and "unknown user": the same generic "incorrect email or password," the same status code.
- Identical timing. Run the password-hash comparison even when the user doesn't exist (hash against a dummy digest), so both paths take the same ~100 ms. Otherwise the timing is the oracle.
- Reset flows say nothing. "If an account exists for that address, we've sent a reset link" — the same message whether or not the email is registered. Never confirm or deny.
Phase 3 — Sessions: the part everyone forgets is still authentication
The user proved who they are once. Now every subsequent request carries a session credential — a cookie or a token — that re-asserts that identity. Stealing or forging that credential is being the user, no password required. This phase is where careful storage and rate limiting get quietly undone.
Where the session lives: cookie vs. localStorage
A hand-built JWT auth often stashes the token in localStorage and sends it in an Authorization header. It's convenient and it has one nasty property: any JavaScript on your page can read it. If an attacker lands a single line of script on your origin — a cross-site scripting (XSS) hole, a compromised npm dependency, a malicious browser extension's injection — they read the token straight out of localStorage and exfiltrate it. Game over, and your hardened hashing never came into play.
The more defensible default is a cookie with the right attributes, because a cookie can be made invisible to JavaScript:
Set-Cookie: session=<opaque-id>; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=1209600HttpOnly— JavaScript cannot read it.document.cookiewon't show it, so an XSS payload can't steal it. This single flag is the difference between "XSS reads your session" and "XSS can't."Secure— sent only over HTTPS, so it can't leak over a plaintext connection.SameSite=Lax(orStrict) — the browser won't attach the cookie to most cross-site requests, which is your front-line CSRF defense (next section).Path/Max-Age— scope and expire it. Short lifetimes limit the damage window of a stolen cookie.
CSRF — making the user's browser act without the user
With a cookie-based session you inherit a new problem: the browser attaches that cookie to every request to your domain, including ones triggered by other sites. An attacker puts a hidden form or fetch on their page that POSTs to https://yourapp.com/delete-account; your visitor's browser dutifully includes their valid session cookie; your server sees an authenticated request and obeys. That's Cross-Site Request Forgery — the user never intended the action, their browser was just an obedient courier.
Layered defense:
SameSite=Laxcookies block the cookie from riding along on cross-site top-level POSTs — this alone stops the classic attack, and it's free.- CSRF tokens for anything state-changing: embed an unpredictable, per-session token in your forms/requests and verify it server-side. The attacker's page can't read it (same-origin policy), so it can't forge it.
- Check
Origin/Refereron state-changing requests and reject mismatches. - And the rule that quietly prevents a lot of CSRF:
GETmust never change state. A link the browser can be tricked into following should only ever read.
Session fixation and rotation
Session fixation is subtle: an attacker plants a known session id in the victim's browser before they log in (via a crafted link, an injected cookie). If your server keeps that same id after authentication, the attacker — who knows the id — is now logged in as the victim. The fix is a one-liner of discipline: issue a brand-new session id at every privilege change — on login, and again on anything sensitive like a password change. Never carry a pre-login id into a logged-in session. Rotate on logout too (and actually invalidate server-side, don't just drop the cookie).
Figure 3 — Session fixation, defeated by one rule: the id a user carries after login must never be one they (or an attacker) could have known before it.
A second factor closes the gap stuffing leaves open
Everything above reduces the odds; multi-factor authentication (MFA) changes the math. Even a correct, stuffed, or phished password is useless without the second factor. The widely-deployable, no-SMS option is TOTP (time-based one-time passwords — the 6-digit codes in Google Authenticator/1Password):
- At enrollment, generate a random shared secret, show it as a QR code (
otpauth://URI), and store the secret per user (encrypted at rest). - At login, after the password checks out, ask for the 6-digit code and verify it against the secret with a ±1 time-step window for clock skew.
- Issue one-time recovery codes at enrollment so a lost phone doesn't mean a lost account.
Avoid SMS as a primary factor where you can — SIM-swapping and SS7 interception make it the weakest of the second factors (still better than nothing). For the highest bar, passkeys / WebAuthn are phishing-resistant by design (the credential is bound to your origin and can't be replayed on a lookalike site) and are the direction the whole industry is heading.
Phase 4 — Federated identity: OAuth's two famous footguns
"Sign in with Google/Apple/GitHub" (the Ch 12 build) removes password storage from your plate entirely — a real security win. But the redirect dance has two slips that turn it into a hole:
- The missing
stateparameter.stateis a random, per-request value you generate, stash in the user's session, send to the provider, and verify on the callback. Skip it and you've built a CSRF on the login itself: an attacker can complete the first half of the flow and trick a victim's browser into finishing it, logging the victim into the attacker's account (or vice versa).statemust be present, unguessable, and checked — no exceptions. (Pair it with PKCE for public clients.) - The open
redirect_uri. The provider sends the authorization code back to a redirect URL. If your app reflects an attacker-controlled redirect target, the code (and the session you mint from it) can be delivered to the attacker. Allowlist exact redirect URIs; never echo back a URL from the query string.
The password-reset flow is a login in disguise
A reset link is a single-use credential that grants full account access — treat it with the same paranoia as the password itself. The common leaks:
- Guessable tokens. Reset tokens must be high-entropy random values, single-use, short-lived (minutes, not days), and invalidated the instant they're used or a new one is requested.
- Enumeration via the reset form — covered above: same response whether or not the email exists.
- Host-header poisoning. If you build the reset URL from the incoming request's
Hostheader, an attacker can set it to their domain and harvest tokens when victims click. Build reset links from a trusted, configured base URL, never from request headers. - Not rotating sessions on reset. A password change should invalidate all existing sessions — otherwise a thief who already had a session keeps it after the legitimate owner resets the password they (rightly) feared was compromised.
Putting it together — the hardened-login checklist
Mini-exercise: audit your own login in 15 minutes
Open your auth code (or the Ch 11 Worker) and answer, in writing:
- What happens to a leaked DB? Find the line that hashes the password. Is it salted, slow, and unique per user? If you can't point at the iteration count or cost factor, that's finding #1.
- Can I tell a real account from a fake one? Submit a login for a known-good email with a wrong password, then for an email you're sure doesn't exist. Compare the response body, status, and time. Any difference is an enumeration leak.
- Where does my session live, and who can read it? If it's in
localStorage, ask what one line of injected script would do. If it's a cookie, confirmHttpOnly,Secure, andSameSiteare all set — check the actualSet-Cookieheader, not your intentions.
Every "hmm, not sure" is a finding. Authentication failures are rarely a single dramatic hole; they're a stack of small defaults nobody turned off.
Challenges
- Build the breached-password gate. Implement the HIBP k-anonymity check in a Worker: SHA-1 the candidate password, send the first 5 hex chars to the range API, and reject if the suffix appears in the response. Prove the full password never leaves your server.
- Make enumeration impossible. Refactor a login endpoint so unknown-user and wrong-password are byte-identical in body, status, and timing — including running the hash comparison against a dummy digest on the unknown-user path. Measure both timings to confirm.
- Add TOTP end to end. Generate a secret, render the
otpauth://QR, verify a 6-digit code with a ±1 step window, and issue 10 single-use recovery codes. Then break it on purpose: confirm a replayed code from the previous time step is rejected. - Rotate everything. Add session-id rotation on login and on password change, and make a password change invalidate all other sessions. Demonstrate that a session captured before the change stops working after it.
- Find your OAuth
statebug. Temporarily removestateverification from a sign-in flow and write out, step by step, the exact login-CSRF an attacker would run. Restoring the check should make the attack you just described impossible.
Key Points
- Storage decides whether a DB leak is fatal. Plaintext and fast hashes lose every account; a salted, slow, per-user hash (Argon2id/scrypt/bcrypt, or high-iteration PBKDF2 on Workers) buys time and turns "all accounts" into "the few weak passwords." The game is asymmetry: cheap to verify once, ruinous to guess billions of times.
- Most takeovers come through the front door. Credential stuffing replays real passwords from other sites' breaches. Rate-limit per account, reject breached passwords, and add a second factor — your own password rules can't help when the password was already leaked elsewhere.
- Don't be an oracle. Different responses or timings for unknown-user vs. wrong-password leak your user list. Make them identical, and let reset flows say "if an account exists…" and nothing more.
- The session is still authentication. An
HttpOnly; Secure; SameSitecookie beats alocalStoragetoken because XSS can't read it; prefer a revocable opaque id; add CSRF tokens; and rotate the session id on every privilege change to kill fixation. - MFA changes the math. TOTP or passkeys make a correct-but-stolen password worthless. Passkeys/WebAuthn are phishing-resistant by design; avoid SMS as the primary factor.
- OAuth's footguns are
stateandredirect_uri. Verifystate, allowlist exact redirect URIs, validate the ID token, and mint your own session after federation. Reset flows are single-use credentials — random, short-lived, built from a trusted base URL, and they must invalidate all sessions on change.
Domain takeover (Chapters 1–2) was about trusting something you didn't control. Broken auth is its mirror: failing to protect something you do. The same temperament fixes both — assume the default is unsafe, name the exact thing you depend on, and verify it against reality instead of intention. Build your login as if the database is already dumped, the password is already leaked, and the user's browser is already being pointed at you by someone else — because for someone, somewhere, all three are already true.
Ship your apps faster
When you're ready to publish your Swift app to the App Store, Simple App Shipper handles metadata, screenshots, TestFlight, and submissions — all in one place.
Try Simple App Shipper