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

  1. Download: Download the lab (zip)
  2. Prerequisites: Docker Engine 20+, Docker Compose v2.
  3. Start it:
    cd owasp-a07-authentication-lab
    docker compose up -d --build
  4. Check it worked: curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:5000/ should print 200.

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

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

  1. Why does credential stuffing succeed even against accounts with "strong," complex passwords?
  2. Why check against a breach corpus in addition to rate limiting, instead of relying on rate limiting alone?
  3. What would happen to this attack if every account required a second factor (TOTP, hardware key) at login?
Show answers

Answers

  1. 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.
  2. 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.
  3. 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.