[Blog](https://excessionlabs.ai/blog/) · 11 November 2026 · [Excession Labs](https://excessionlabs.ai/about/)

# Credential stuffing: a local test and the fixes that work

Source page: https://excessionlabs.ai/blog/credential-stuffing/

Credential stuffing replays username and password pairs leaked from another site against your login. In our local fixture, 100 invented pairs opened 12 of 60 synthetic accounts, and the per-account lockout never fired because the run made one attempt per account. A second factor alone opened none, a leaked-password check alone left 3 open, and a per-client attempt budget limited the damage to the first 10 attempts.

This is a scripted Python and curl experiment with downloadable source and responses. No AI agent or model ran the test.

On this page: [Definition](https://excessionlabs.ai/blog/credential-stuffing/#definition) · [Stuffing vs spraying](https://excessionlabs.ai/blog/credential-stuffing/#spraying) · [Scope](https://excessionlabs.ai/blog/credential-stuffing/#scope) · [Reproduce the run](https://excessionlabs.ai/blog/credential-stuffing/#reproduce) · [Observed results](https://excessionlabs.ai/blog/credential-stuffing/#results) · [Verify the finding](https://excessionlabs.ai/blog/credential-stuffing/#finding) · [Fix and retest](https://excessionlabs.ai/blog/credential-stuffing/#fix) · [Evidence](https://excessionlabs.ai/blog/credential-stuffing/#evidence) · [Limits](https://excessionlabs.ai/blog/credential-stuffing/#limits)

## What credential stuffing is, and why lockout misses it

Credential stuffing is the automated replay of username and password pairs that leaked from another site. It works when people reuse a password. The [OWASP Credential Stuffing Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Credential_Stuffing_Prevention_Cheat_Sheet.html) defines it as testing username and password pairs obtained from the breach of another site. No guessing is involved, because each pair once worked as a login.

Per-account lockout counts failures on one account. A stuffing run sends one attempt per account, so no counter climbs. The cheat sheet adds that toolkits can spread requests over many addresses through proxy networks, which can defeat IP block lists and per-address rate limits.

## Credential stuffing vs password spraying

Brute force tries many passwords against one account. Password spraying tries one weak password against many accounts. Credential stuffing tries known pairs, one password for each account, taken from a leak. The cheat sheet says the defenses are often the same for all three.

## Scope of the fixture

- **Target:** short-lived servers on `127.0.0.1` with operating-system ports. The login is `POST /login`. A `GET /_stats` counter page serves the runner.

- **Accounts:** 60 synthetic accounts. Passwords, salts and second-factor secrets come from a fixed seed. An account locks after 5 failed attempts.

- **Leaked list:** 100 invented pairs. 12 still work because the user reused the password, 18 name a real account with a different password, and 70 name accounts that do not exist.

- **Corpus:** SHA-1 digests of the leaked passwords, minus the passwords of 3 of the 12 working pairs. The gap stands in for a breach feed that misses some passwords.

- **Second factor:** a time-based one-time password as in [RFC 6238](https://www.rfc-editor.org/rfc/rfc6238).

- **Requests:** 614 curl login requests over 8 short-lived servers.

The vulnerable mode is insecure on purpose, so the fixture binds only to loopback. Testing a real system needs written permission, test accounts the owner controls and a cap on attempts. The [Acceptable Use Policy](https://excessionlabs.ai/aup/) describes authorized use. This article gives no permission to test another system.

## Run the downloadable fixture

Download [fixture.py](https://excessionlabs.ai/blog/evidence/credential-stuffing/fixture.py) and [verify.py](https://excessionlabs.ai/blog/evidence/credential-stuffing/verify.py) into one directory. You need Python 3.9 or later and curl. The code uses only the standard library. Inspect the source, then run:

```
python3 verify.py --output rerun
```

The output directory must be new. The runner starts a fresh server for each configuration, sends each pair once, saves every raw response and stops the server. Each curl request bypasses proxies and has short timeouts.

Our recorded command was `python3 verify.py --output recorded-run`. It exited with `{"checks": 23, "passed": true, "failures": []}`, which means every observed outcome matched the expected one, including the vulnerable result. The run used Python 3.15.2 and curl 8.7.1 on macOS ARM64 on 11 November 2026. Versions, timestamps and hashes are in [results.json](https://excessionlabs.ai/blog/evidence/credential-stuffing/recorded-run/results.json). A rerun uses different ports and sessions.

## Observed results from the stuffing runs

Each configuration got a fresh server and the same 100 pairs in the same order. A pair counts as confirmed when the response shows the password was right: a 200, or a 401 with `second_factor_required`.

Recorded stuffing runs: 100 login attempts each

| Configuration | Controls on | Attempts that reached the password check | Sessions opened | Pairs confirmed correct |
| --- | --- | --- | --- | --- |
| Vulnerable | Lockout only | 100 | 12 | 12 |
| Fixed | Budget, leaked-password check, second factor | 10 | 0 | 0 |
| Budget only | Budget | 10 | 0 | 0 |
| Budget only, working pairs first | Budget | 10 | 10 | 10 |
| Leaked check only | Leaked-password check | 100 | 3 | 3 |
| Second factor only | Second factor | 100 | 0 | 12 |

In the vulnerable run, 88 attempts returned 401 and 12 returned 200 with a session. The working pairs sat at positions 14 to 86. The server counters recorded 0 lockouts and a highest failure count of 1 on any account. Lockout does work for the case it was built for: five wrong guesses at one account returned 401 and the sixth returned 423 Locked.

The budget allows 10 attempts per address per minute. The 11th attempt and the 89 after it got 429 with `Retry-After: 60`. The first working pair is at position 14, so the run ended before any pair worked. With the working pairs listed first, the same budget let 10 sessions open. A budget caps what one address can try. It does not bring the count to zero.

The leaked-password check refused 9 of the 12 working pairs with the same 401 a wrong password gets. Three still opened, because we left their passwords out of the corpus. The second factor stopped all 12 sessions, yet it answered `second_factor_required` 12 times, which tells the sender the password was right. The cheat sheet says to notify the user when a correct password then fails the MFA check.

Control requests: legitimate use and known costs

| Request | Mode | Observed |
| --- | --- | --- |
| Owner, right password | Vulnerable | 200 |
| Owner, right password, no code | Fixed | 401 second_factor_required |
| Owner, right password, current code | Fixed | 200 |
| Owner, wrong password | Fixed | 401 invalid_credentials |
| Owner whose password is in the corpus, right code | Fixed | 401 invalid_credentials |
| Owner at the attacker's address after the run | Fixed | 429 |

The first four rows show a legitimate user can still sign in and that bad input is rejected. The last two rows are costs. An owner who reused a leaked password must reset it before signing in, and a user who shares the attacker's address is refused for the rest of the window.

## Verify a stuffing finding before you report it

In the recorded vulnerable run, attempt 14 used port 52542. This is the executed command, reformatted across lines:

```
curl --noproxy '*' --silent --show-error --include \
  --http1.1 --connect-timeout 1 --max-time 3 --request POST \
  --header 'Content-Type: application/json' \
  --data-binary '{"password": "reuse-ab72ea37", "username": "user31@example.test"}' \
  http://127.0.0.1:52542/login
```

The [recorded response](https://excessionlabs.ai/blog/evidence/credential-stuffing/recorded-run/stuffing-vulnerable.http) has this status and body; headers are omitted:

```
HTTP/1.0 200 OK

{"session": "eb8354e5a7e9a133", "status": "ok", "username": "user31@example.test"}
```

Three facts make this a finding. The body carries a session for the account the pair named. A wrong password for a known account fails in the same fixture with 401, so the 200 means something. No lock or limit fired: the counters show 0 lockouts and 0 budget denials. Record the source of the pair list, the attempt number, the status, the account in the body and the retest result. The impact here is 12 of 60 synthetic accounts. We did not test what a stolen session can reach; [API authorization testing](https://excessionlabs.ai/blog/api-authorization-testing/) covers that step.

## Credential stuffing prevention: controls that work across accounts

Per-account counting cannot see a stuffing run, so each fix counts or checks something across accounts. Fixed mode turns on three controls in the same source file. The attempt budget counts attempts by the real peer address, across all accounts:

```
client, now = self.client_address[0], time.monotonic()
hits = [t for t in s.hits.get(client, []) if now - t < BUDGET_WINDOW]
if len(hits) >= BUDGET_LIMIT:
    s.hits[client] = hits
    s.stats["budget_denials"] += 1
    retry = str(int(BUDGET_WINDOW - (now - hits[0])) + 1)
    return self.reply(429, {"error": "too_many_attempts"}, {"Retry-After": retry})
```

The key is the peer address. A key taken from a header would let the client reset its own count, a failure the [API rate limiting](https://excessionlabs.ai/blog/api-rate-limiting/) post reproduces. The leaked-password check compares the submitted password with the corpus:

```
if correct and "leaked" in s.controls and sha1_hex(password) in s.corpus:
    s.stats["leaked_denials"] += 1
    s.must_reset.add(username)
    correct = False
```

The refusal looks like a wrong password, so the reply does not reveal which pairs worked. The cheat sheet describes checking new passwords against breach data, for example with the Pwned Passwords service. Checking at sign-in is our addition, and it catches only passwords in your corpus. The second factor comes last. After the password passes, a request without a code gets this answer, and a request with a code must match the owner's current code within one 30-second step either side:

```
if "mfa" in s.controls:
    if not code:
        s.stats["mfa_challenges"] += 1
        return self.reply(401, {"error": "second_factor_required"})
```

The runner checks the TOTP code against the RFC 6238 SHA-1 test vector for T=59, which is 94287082 with eight digits. The cheat sheet calls MFA "by far the best defense against the majority of password-related attacks, including credential stuffing and password spraying." In this run the second factor alone opened no accounts. The [2FA bypass](https://excessionlabs.ai/blog/2fa-bypass/) post tests how a second factor itself can fail.

The retest sent the same 100 pairs to fixed mode: 10 attempts reached the password check, 90 got 429 and no session opened. Read that zero with care: the first working pair sits at position 14. Switching the budget off stands in for an attacker spread over many addresses. Then the leaked-password check alone left 3 accounts open and the second factor alone left none.

## Inspect the evidence bundle

- [Machine-readable results](https://excessionlabs.ai/blog/evidence/credential-stuffing/recorded-run/results.json): 23 checks with expected and observed values.

- [Raw responses](https://excessionlabs.ai/blog/evidence/credential-stuffing/recorded-run/stuffing-vulnerable.http): one file per stuffing run with all 100 responses. This link opens the vulnerable run.

- [Exact recorded curl commands](https://excessionlabs.ai/blog/evidence/credential-stuffing/recorded-run/commands.sh): the ports belong to the finished run.

- [SHA-256 manifest](https://excessionlabs.ai/blog/evidence/credential-stuffing/SHA256SUMS), an integrity check with no independent attestation, plus the [README](https://excessionlabs.ai/blog/evidence/credential-stuffing/README.txt), [server source](https://excessionlabs.ai/blog/evidence/credential-stuffing/fixture.py) and [verification source](https://excessionlabs.ai/blog/evidence/credential-stuffing/verify.py).

The fixture is also on GitHub: [web-security-fixtures](https://github.com/Excession-Labs/web-security-fixtures). All accounts, passwords and secrets are synthetic, and the files hold the actual response text with dates and server headers.

## What this experiment does not cover

One list of 100 pairs, one order and one run per configuration is a small sample. The 12 and the 3 come from how we built the data. They say nothing about rates in real systems.

The fixture sees one client address and trusts the socket peer. Behind a reverse proxy that address is the proxy, so the budget would need a forwarded address your proxy sets. It has no CAPTCHA, fingerprinting, bot detection, risk-based step-up or user notification, which the cheat sheet lists. We did not run a password spray, relay a code through phishing, guess codes, query a real breach service or test username enumeration.

Excession Labs runs this kind of login testing in its [security assessments](https://excessionlabs.ai/security-assessment/), with test accounts and an attempt cap agreed beforehand.
