Tutorials Web Security Series Chapter 3

How Logins Get Broken — Credential Stuffing, Session Hijacking, and the Auth Defenses That Actually Hold

SecurityChapter 3 of the Web Security Series32 minJune 12, 2026Intermediate

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.

Loading diagram…

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.

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.

Loading diagram…

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:

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:

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=1209600

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:

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).

Loading diagram…

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):

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 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:

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:

  1. 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.
  2. 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.
  3. 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, confirm HttpOnly, Secure, and SameSite are all set — check the actual Set-Cookie header, 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Find your OAuth state bug. Temporarily remove state verification 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

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.

Ch 2: Is my domain hacked? — investigate, then auditComing Soon →
InfrastructureInternet Infrastructure SeriesPractical internet infrastructure concepts: DNS, RDAP, WHOIS, IP allocation, ASNs, and registries.Production WebProduction Web Apps SeriesProduction patterns for web apps: caching, rate limiting, webhooks, queues, cron jobs, and idempotency.CloudflareCloudflare Feature FocusFocused Cloudflare tutorials for Workers, R2, Stream, Durable Objects, and edge deployment.

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
5 free articles remainingSubscribe for unlimited access