OWASP Top 10:2025 Course — Module 7 of 10. Course home · Previous: Module 6 — Insecure Design · Next: Module 8 — Software/Data Integrity Failures
Learn first: what is an Authentication Failure
A07:2025 covers weaknesses in how an app proves "you are who you say you are." Credential stuffing is the volume-attack version: take username/password pairs leaked from other breaches (people reuse passwords constantly) and try them against your login form. With no rate limiting, no lockout, and no check against known-breached passwords, every pair gets a fast, free answer.
wordlists/breached-creds.txt (sample pairs, lab-only)
user-a:pw-variant-1 user-a:pw-variant-2 user-b:pw-common-1 user-c:pw-common-2 ...
│ │ │ │
▼ ▼ ▼ ▼
POST /login POST /login POST /login POST /login
fail (200) 302 SUCCESS 302 SUCCESS 302 SUCCESS
No throttling -> 12 attempts in under a second -> 3 accounts compromised
Rate limiting plus a local breach-password check turns the same 12 requests into zero compromised accounts.
Scenario briefing
You’re load-testing — and security-testing — a client’s login page before a marketing push drives new signups. You’ve been handed a lab-only "leaked credentials" sample (the kind every company eventually finds mentioned in a breach-notification feed) and asked whether any of it still works.
Lab setup
- Download: Download the lab (zip)
- Prerequisites: Docker Engine 20+, Docker Compose v2.
- Start it:
cd owasp-a07-authentication-lab docker compose up -d --build - Check it worked:
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:5000/should print200.
Missions
Mission 1 — stuff the vulnerable app. Run the attack script against the undefended login.
Show solution
Solution — Mission 1
docker compose exec vulnerable sh -c "cd /work/vulnerable && python ../stuff.py ../wordlists/breached-creds.txt"
[02] user-a : <matched> -> HTTP 302 SUCCESS (logged in)
[05] user-b : <matched> -> HTTP 302 SUCCESS (logged in)
[07] user-c : <matched> -> HTTP 302 SUCCESS (logged in)
Compromised accounts (3)
12 attempts, under a second, three accounts compromised, zero friction. Submit FLAG{a07-credential-stuffing-success} once you’ve confirmed one of the accounts is compromised.
Mission 2 — run the same list against the fixed app. Watch which defense stops each attempt.
Show solution
Solution — Mission 2
docker compose exec fixed sh -c "cd /work/fixed && python ../stuff.py ../wordlists/breached-creds.txt"
[02] user-a : <matched> -> HTTP 200 CREDENTIALS VALID but BLOCKED (breached password)
[06] user-b : <other> -> HTTP 429 BLOCKED (rate limited)
...
No accounts compromised.
A correct password is rejected outright when it matches a local breach corpus (checked offline, k-anonymity style, like HaveIBeenPwned’s Pwned Passwords API) — even before rate limiting kicks in a few requests later.
Mission 3 — confirm the hardened cookie. Log in as a user not in the breach list and inspect the response.
Show solution
Solution — Mission 3
curl -si -X POST http://127.0.0.1:5001/login --data-urlencode "username=frank" --data-urlencode "password=<a strong, unleaked password>"
Set-Cookie: session=...; HttpOnly; Path=/; SameSite=Lax; Expires=...
A clean login (frank’s password isn’t in the breach corpus) still succeeds — the defenses target credential stuffing patterns, not legitimate users — and the session cookie itself is hardened against theft and cross-site use.
Mission 4 — add the same defenses to the vulnerable app. Port over rate limiting and the breach check.
Show solution
Solution — Mission 4
from flask_limiter import Limiter
limiter = Limiter(app, key_func=get_remote_address, default_limits=["5 per minute"])
@app.route("/login", methods=["POST"])
@limiter.limit("3 per minute", key_func=lambda: request.form.get("username", ""))
def login():
if is_breached_password(password):
return "Please choose a different password", 403
...
Restart the vulnerable service with your patch and run python3 lab.py verify — it should now behave like the fixed app for the same credential list.
Hints
- Both
stuff.pyruns use the exact same wordlist — the only variable is which app you point it at. stuff.pyrefuses to target anything other than127.0.0.1/localhoston purpose; don’t try to repoint it.breach_check.pyreproduces the HaveIBeenPwned Pwned Passwords protocol entirely offline against a small local corpus — no real API calls happen in this lab.
Fix it + re-test
Two complementary defenses, already in app/fixed/app.py: Flask-Limiter caps both per-IP and per-account attempt velocity (429 responses), and a local breach-password check rejects known-compromised passwords even when they’re technically "correct" for that account. Re-run stuff.py against the fixed app — zero compromised accounts, every time.
Detect it
Rate-limiter 429 bursts are themselves a detection signal — log and alert on them. Separately, watch for one IP attempting logins for many distinct usernames in a short window (stuffing), versus many attempts for one username (classic brute force) — they call for different response playbooks.
Quick self-check
- Why does credential stuffing succeed even against accounts with "strong," complex passwords?
- Why check against a breach corpus in addition to rate limiting, instead of relying on rate limiting alone?
- What would happen to this attack if every account required a second factor (TOTP, hardware key) at login?
Show answers
Answers
- Credential stuffing doesn’t guess passwords — it replays passwords a user already chose and that already leaked from an unrelated breach; complexity doesn’t matter if the attacker already has the exact value because the user reused it.
- Rate limiting slows down automated guessing but doesn’t stop a single, correct attempt using a known-breached password — the breach check closes that gap by rejecting the password itself, independent of how many attempts were made.
- A second factor would stop the attack even with the correct password, because the attacker’s stuffing list only contains passwords, not the user’s physical device or authenticator — this is why MFA is the single highest-leverage defense against credential stuffing specifically.