# Rate limiting and credential stuffing: how to test safely

Source: https://penetrationtestservices.com/guides/rate-limiting-and-credential-stuffing/
Publisher: Excession Labs (penetrationtestservices.com)
Last updated: 2026-11-09

Credential stuffing testing and rate limit tests done safely: OWASP OAT-008, a bounded test design, test cases for login, reset and MFA, and how to fix gaps.

> **Short answer:** Credential stuffing is the automated use of stolen username and password pairs to log in (OWASP OAT-008). Testers check whether login, reset, MFA and API routes limit repeated attempts, using test accounts they own, a stated request budget and a stop rule. They never test accounts they do not own. Fixes combine per-account and per-source limits, detection and MFA.

Credential stuffing is the automated use of stolen username and password pairs to log in. OWASP lists it as Automated Threat OAT-008 and describes it as "mass log in attempts used to verify the validity of stolen username/password pairs." Rate limiting is the main control that makes it slower and costlier for attackers.

Testers check rate limits and credential stuffing defenses with test accounts they own, a request budget and a stop rule, and never with accounts they do not own. This work belongs in the [authentication testing](/guides/authentication-testing-checklist/) part of a pentest.

## What is the difference between credential stuffing and brute force?

Credential stuffing tries pairs that were leaked from other sites. Brute force guesses passwords. Both send many login attempts, but they look different in logs and need different limits:

| Attack | Input | Typical pattern | Control that helps |
|---|---|---|---|
| Credential stuffing (OAT-008) | Leaked pairs, one try per account | Many accounts, few tries each, many sources | Per-source and global limits, breach-password checks, MFA, bot detection |
| Brute force | Guessed passwords | Many tries on one account | Per-account lockout or delay |
| Password spraying | A few common passwords | Many accounts, one password each | Detection across accounts, MFA |

A per-account lockout alone does not stop credential stuffing, because each account sees only one or two tries.

## Which routes need rate limits?

Test every route that checks a secret or costs money. OWASP API4:2023 says an API is vulnerable when limits are "missing or set inappropriately."

- Login (web, mobile API, legacy routes).
- Password reset request and token submission.
- MFA code entry and resend.
- Sign-up and email verification.
- Account lookup and invitation routes.
- Anything that sends email or SMS, or calls a paid third-party API.
- Search, export and other heavy routes.

## How do you design a safe, bounded test?

A safe test has a written scope, owned accounts, a budget and a stop rule. Set all four before you send any request.

1. Authorization. Get written permission from the system owner. State the hosts and routes.
2. Test accounts only. Create accounts that the tester owns. Use unique, fake passwords. Do not use leaked credential lists or real customer emails.
3. Request budget. Set a maximum number of requests per route, and a maximum rate. For example, 20 attempts over one minute against one test account.
4. Cost cap. For email, SMS or paid APIs, set a maximum count and state the cost. Use test inboxes and numbers you own.
5. Stop rules. Stop at the first sign of a lockout, a 429, a slowdown, a block page, or any report from the owner's contact.
6. Window and contact. Run in an agreed window with a named contact who can pause the test.
7. Record everything. Save requests, responses and timestamps.

> **Authorization limit:** Test rate limits and login controls only on accounts you own and on systems you are authorized to test. A written scope for the system does not give permission to test other people's accounts.
## What do the test cases look like?

Each case has a bounded request count and a clear result. Run the control first: a normal login with the right password.

| Case | Action (test account, bounded) | Good result | Finding if |
|---|---|---|---|
| Per-account limit | 10 wrong passwords for one test account | Delay, lockout or 429 within a few tries | All 10 get the same fast reply |
| Correct password after failures | Right password after the limit | Blocked or step-up while the limit is active | Login succeeds without delay |
| Per-source limit | 20 wrong logins across 5 owned test accounts, from one address | 429 or block | No limit across accounts |
| Reset request flood | 5 reset requests for one test inbox | Throttled, same message | Five emails sent |
| MFA code tries | 10 wrong codes in one session | Session invalidated or throttled | Unlimited tries |
| API route | Same login via the mobile API route | Same limits as web | No limit on the API route |
| Header trust | `X-Forwarded-For` set to a different value | Limit unchanged | Limit resets when the header changes |
| Response consistency | Compare locked and open account replies | Generic message | Message reveals lockout and account existence |

The `X-Forwarded-For` check shows whether the limit trusts a client-set header. Report that as a design flaw. Do not build a rotation tool.

> **Illustrative bounded test:** This is a demonstration. No real customer system was tested. The host and account are fictional.
> 
> A tester sends 10 wrong-password logins for one owned test account, 1 per second:
> 
> ```http
> POST /api/v1/login HTTP/1.1
> Host: app.harbor-notes.example.com
> Content-Type: application/json
> 
> {"email": "tester-a@example.com", "password": "wrong-value-01"}
> ```
> 
> Attempts 1 to 5 return `401`. Attempt 6 returns:
> 
> ```http
> HTTP/1.1 429 Too Many Requests
> Retry-After: 900
> Content-Type: application/json
> 
> {"error": "too_many_attempts"}
> ```
> 
> The limit works for this account. The tester then repeats the test through the mobile route `/api/m/login` to check that the same limit applies.
## How do you read the results?

A working control slows or stops attempts on every route. Check what the limit keys on (account, source, device), whether it covers every route, and whether the reply leaks anything.

Report what held as well as what failed. The finding "mobile login has no limit" reads better with the web route's limit listed as a control that held.

## How do teams fix weak rate limits?

Use layers, because each control covers a gap in another:

- Limit per account and per source, with a global cap on failures.
- Slow responses after repeated failures (progressive delay) before a hard lockout. A hard lockout lets an attacker lock real users out.
- Check new passwords against known breached lists.
- Offer MFA, and require it for risky logins.
- Detect bots and unusual patterns, for example many accounts from one source.
- Return the same reply for unknown users, locked users and wrong passwords.
- Do not trust client headers for the source address. Take it from your edge proxy.
- Alert on login failure spikes.

```text
key = hash(account_id) + ":" + source_ip
failures = counter.increment(key, window = 15 minutes)
if failures > 5: respond 429 with Retry-After, log "rate_limited"
global_failures = counter.increment("login", window = 1 minute)
if global_failures > threshold: raise alert
```

## How do you put rate limit checks in a pentest scope?

Ask your tester to include rate limit checks in the authentication scope, with the budget in writing. For API routes, share the API description so testers cover every login and reset route; see [API penetration testing](/api-penetration-testing/). The wider API risk list is in the [OWASP API Security Top 10 guide](/guides/owasp-api-security-top-10/). For abuse of buying, booking or sign-up flows, read the [business logic guide](/guides/business-logic-vulnerabilities/).

## What does a rate limit finding look like?

A finding states which route lacked a limit, the budget used and what the tester saw. The illustrative write-up below shows each part.

> **Illustrative finding write-up:** This is a demonstration. No real customer system was tested. The app and host are fictional.
> 
> | Field | Detail |
> |---|---|
> | Title | Mobile login route has no attempt limit |
> | Label | Observed |
> | Route | `POST /api/m/login` |
> | Accounts | one test account created for this test |
> | Budget | 20 wrong-password attempts over 20 seconds, then one correct login |
> | Result | All 20 attempts return `401` with no delay. The correct login then succeeds. |
> | Controls that held | The web route `POST /api/v1/login` returns `429` after 5 failures. Unknown and known users get the same reply. |
> | Impact | An attacker who uses the mobile route faces no limit on attempts. |
> | Fix | Apply the same limit in the shared login service for all routes. |
The budget belongs in the finding: it shows the test was bounded and lets a reader repeat it.

## How should you plan the cost of a test?

Some routes cost money or cause side effects. List them before the test and set a cap for each:

| Route type | Cost or side effect | Cap to set |
|---|---|---|
| Email send | Provider fee, risk to sender reputation | A few messages to owned inboxes |
| SMS or voice | Per-message fee | A few messages to owned numbers |
| Paid third-party API | Per-call fee | A stated count and a stated price |
| File upload | Storage and scan cost | A few small files |
| Heavy search or export | Server load | One request at a time, small size |

Write the caps in the scope. Report the actual count and cost in the final report. If a cap is reached before the limit appears, report that as a result: "no limit seen within the cap".

## Questions

### Will a rate limit test lock out real users?

It can, if you aim it at real accounts. Safe tests use test accounts only. Lockout is per account in many systems, so a test on a test account affects no real user. Agree the rules and a contact in writing first.

### How many requests is a safe test?

There is no universal number. Choose a budget from the control you want to test. If the limit is 5 failures in 15 minutes, 10 to 20 attempts show whether it works. State the number, the accounts and the stop rule in the scope.

### Is a CAPTCHA a rate limit?

No. A CAPTCHA adds friction but is not a limit on attempts. Testers check the server-side limit too, since attackers can solve or bypass CAPTCHAs.

### Should the test try IP rotation to evade a limit?

We do not recommend evasion. A test shows whether the control exists and what it keys on (account, IP, device). Describe that and the fix. Evasion tooling is out of scope for this guide.

## Sources

- [OWASP Automated Threats: OAT-008 Credential Stuffing](https://owasp.github.io/www-project-automated-threats-to-web-applications/assets/oats/EN/OAT-008_Credential_Stuffing)
- [OWASP API Security Top 10 2023: API4 Unrestricted Resource Consumption](https://api-security.owasp.org/editions/2023/en/0xa4-unrestricted-resource-consumption/)
- [OWASP Credential Stuffing Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Credential_Stuffing_Prevention_Cheat_Sheet.html)
- [OWASP WSTG: Testing for Weak Lock Out Mechanism](https://wstg.owasp.org/v4.2/4-Web_Application_Security_Testing/04-Authentication_Testing/03-Testing_for_Weak_Lock_Out_Mechanism)
