Login Attacks & Defences

Chapter 3
Login Attacks & Defences
Brute force, credential stuffing, password spraying — and how to blunt them

Chapter 2 made stolen hashes hard to crack. This chapter defends the live login endpoint — where attackers don't need your database, they just guess against your form. The attacks differ in how they guess, and the defences differ accordingly, but they share a theme: make guessing slow, costly, and visible.

The Family of Guessing Attacks

AttackHow it guessesTargets
Brute forcetries many passwords against one accounta single targeted user
Dictionarytries common passwords / wordlists (not all combinations)a single user, efficiently
Credential stuffingreplays real username+password pairs from other breachesmany accounts, reused passwords
Password sprayingtries a few common passwords across many accountsmany users; evades per-account lockout
Credential stuffing is the big one today — and it's why password reuse is dangerous
The most common real-world account-takeover attack isn't classic brute force — it's credential stuffing. Attackers take the billions of email:password pairs leaked in past breaches and replay them against your login, betting that people reuse passwords. They're not guessing — they're using known-correct credentials from elsewhere, so a meaningful percentage just work. This is why "use a unique password per site" matters, why breach-password checks (below) help, and why MFA (Chapter 6) is the real backstop: a stuffed password alone shouldn't be enough.

Defence 1: Rate Limiting

The first line of defence is limiting how many login attempts a given source can make in a window. Track attempts and reject once a threshold is exceeded:

// conceptual: cap attempts per IP + per account over a time window if (attempts(ip) > 10 in last 10 min) return 429; // Too Many Requests if (attempts(account) > 5 in last 15 min) slowDown();

Rate limit on multiple keys — by IP, by account, and ideally combinations — because each alone has a gap: per-account limits don't stop spraying (one try per account across thousands), and per-IP limits don't stop a botnet (one try per IP across thousands of IPs). Pair coarse IP limits with per-account limits to cover both.

Defence 2: Account Lockout — Carefully

Locking an account after N failed attempts is classic, but it's a double-edged sword:

Naive account lockout creates a denial-of-service against your own users
If you hard-lock an account after, say, 5 failures, an attacker can deliberately lock out any user by submitting wrong passwords for them — a trivial denial-of-service, and a nuisance-weapon against specific people. It also doesn't stop password spraying (which makes only 1–2 attempts per account). Better approaches: exponential backoff (each failure increases the delay before the next attempt is accepted) and temporary, self-clearing lockouts rather than permanent ones, combined with step-up friction (CAPTCHA / MFA challenge) on suspicious activity instead of a hard block. The goal is to slow attackers without handing them an easy way to lock out legitimate users.

Defence 3: Don't Leak Which Part Was Wrong (User Enumeration)

A subtle but important defence: your login (and signup, and password-reset) responses must not reveal whether a username exists. Compare:

// LEAKY — different messages reveal valid usernames "No account with that email" // -> email is NOT registered "Incorrect password" // -> email IS registered (enumeration!) // SAFE — identical generic message either way "Invalid email or password"

Distinct messages let an attacker enumerate valid accounts (which then makes credential stuffing and spraying far more efficient — they only target real accounts). Beyond the message text, watch for timing leaks: if a valid username runs the slow password hash but an invalid one returns instantly, the response-time difference reveals validity. Mitigate by running a dummy hash comparison even when the user doesn't exist, so both paths take similar time. The same generic-response discipline applies to signup ("if that email is new, we've sent a link") and password reset ("if that account exists, we've emailed instructions").

Defence 4: CAPTCHA & Step-Up Friction

Introduce friction that's cheap for humans but expensive for bots — after signs of abuse rather than on every login (to avoid harming UX). A CAPTCHA (or a modern proof-of-work / risk-based challenge) triggered after a few failures, or for traffic from suspicious IP ranges, sharply raises the cost of automated guessing. Risk-based authentication goes further: challenge harder when the login looks unusual (new device, new country, impossible travel) and stay frictionless when it looks normal.

Defence 5: Breached-Password & Strength Checks

Stop bad passwords at the source. Modern guidance (NIST) favours checking new passwords against breach corpora rather than imposing arbitrary composition rules:

  • Block known-breached passwords — services like Have I Been Pwned's k-anonymity API let you reject passwords that appear in breach datasets without sending the password itself. This directly defangs credential stuffing.
  • Encourage length over complexity — long passphrases beat short "P@ssw0rd!"-style strings; the old "must have an uppercase, number, and symbol" rules push users toward predictable patterns.
  • Don't force frequent rotation — mandatory periodic changes lead to weaker, incremental passwords (Spring2024!Summer2024!); rotate only on suspected compromise.
The honest hierarchy: MFA outranks all of these
Rate limiting, lockouts, enumeration-hardening, and CAPTCHAs all raise the cost of guessing, but a sufficiently determined or distributed attacker (botnet + leaked credentials) can still grind through. The defence that actually changes the game is multi-factor authentication (Chapter 6): even a correct, stuffed password fails without the second factor. Treat the defences in this chapter as essential friction that protects single-factor accounts and buys time — but push users toward MFA, which makes a guessed/stuffed password insufficient on its own.

Hands-On Exercises

Exercise 1

Distinguish brute force, credential stuffing, and password spraying — how each guesses and why per-account lockout stops one but not the others. Then explain why rate limiting must key on both IP and account.

📄 View solution
Exercise 2

Show how a login form leaks valid usernames via (a) different error messages and (b) response timing. Write the fixes for both (generic message + dummy hash), and explain why the same discipline must extend to signup and password-reset responses.

📄 View solution
Exercise 3

Explain why naive "lock after 5 failures" account lockout can be turned into a denial-of-service and still fails to stop spraying. Design a better defence layering (backoff, temporary locks, IP+account rate limits, breach checks, step-up CAPTCHA, MFA) and justify the ordering.

📄 View solution

Chapter 3 Quick Reference

  • Brute force / dictionary — many guesses vs one account; credential stuffing — replays real breached pairs; spraying — few common passwords vs many accounts
  • Credential stuffing is the dominant real-world ATO attack — known-correct credentials, exploiting password reuse
  • Rate limit on multiple keys (IP and account) — per-account misses spraying, per-IP misses botnets
  • Account lockout is double-edged — naive hard locks enable DoS against your own users & miss spraying; prefer backoff + temporary locks + step-up
  • Prevent user enumeration — identical generic responses on login/signup/reset, and equalize timing (dummy hash when user absent)
  • CAPTCHA / risk-based step-up after signs of abuse, not on every login
  • Breach checks (HIBP k-anonymity) + length over complexity + no forced rotation (NIST guidance)
  • MFA outranks all of these — it makes a guessed/stuffed password insufficient (Chapter 6)
  • Next chapter: sessions & cookies — how the session works after login