Rate Limit Scanner
A login form that answers every request will answer ten thousand of them. The scanner picks the endpoints where guessing pays (login, signup, password, reset, token, otp) and measures what the server does under load: how many requests pass before the first 429, what happens when a burst arrives at once, and whether switching to a new identity resets the counter.
Counting to the first 429
An endpoint is tested when its URL contains /login, /signin, /auth, /signup, /register, /password, /reset, /forgot, /token or /otp. The first measurement sends the endpoint's own request over and over, one at a time, and waits for a 429.
1The scanner filters the endpoints by URL substring, then works through them one at a time, within a shared time budget. If a session was captured, it rides along, so the numbers describe the logged-in user's limit.
2It sends the endpoint's own request sequentially, 0.05 s apart, up to 150 times, and counts every reply that is not a 429. This limiter lets 20 through, then answers 429.
3A 429 at all means a limiter exists. The scanner records the count as an INFO finding, confidence 0.90: a measured threshold you can judge against your own policy, not a vulnerability.
4This second endpoint never answers 429. The status of the replies does not matter, only that none is a 429, so even a stream of 401s from a login form counts as accepted.
5150 requests and no 429 is HIGH, confidence 0.85. That is the brute-force and credential-stuffing condition: the attacker is never slowed down.
Concurrent bursts
A limiter that counts per minute may not react to a slow loop but still trip on a flood. The second measurement sends requests all at once, in three sizes.
1The scanner launches 10, then 50, then 100 requests at once with asyncio.gather, always against the same endpoint and with the endpoint's own method.
2All 10 get through and none is a 429. The burst produces no finding, and the scanner waits 5 seconds so the counter can reset before the next size.
3In the burst of 50, 30 replies are accepted and 20 are 429. A mixed burst is INFO, confidence 0.90: it reports roughly where the limiter bites, so you can compare it with what you intended. It is skipped when the sequential run already measured a threshold.
4The scanner stops at the first burst that shows a threshold, and the whole burst phase is skipped when the sequential run already measured one. The burst of 100 is never sent.
New identity, same address
The last measurement asks what the limiter keys on. It exhausts the limit with one made-up identity, then tries a second made-up identity from the same machine.
1The scanner invents an identity, Bearer test-rate-limit-user-alpha, and sends requests with it.
2It stops at the first 429, after at most 20 requests. If 20 requests never trigger a 429 there is nothing to compare and the test ends with no finding.
3Straight away it sends one request from the same address with a different invented identity, Bearer test-rate-limit-user-beta.
4If beta is refused too, the limiter ignores who is asking and the scanner reports nothing. If beta passes, MEDIUM, confidence 0.90: changing the Authorization header was enough to get a fresh allowance.
5On an authenticated scan, the crawl's session would otherwise ride on both identities and make them the same user. This test is the one exception to session injection: it blanks every injected header and sets only the test one.
The trigger is "a new identity from the same address was not refused". The finding keeps the title "Rate limit appears IP-based only" so existing baselines still match, but its description and detail now state the actual condition: the limiter is keyed on the Authorization header. The trigger itself is unambiguous: the allowance reset when only the Authorization header changed. Treat it as a limit that is keyed on a client-controlled value.
Why it matters
Without rate limiting, attackers can brute-force passwords at thousands of tries per minute against a login endpoint, run credential stuffing with stolen username and password pairs from other breaches, enumerate accounts with rapid-fire email checks, and overwhelm the server with requests.
Apple's Find My iPhone API lacked rate limiting, allowing attackers to brute-force celebrity iCloud passwords. This led to the mass leak of private photos.
Credential-stuffing attacks against Dunkin's app, which had no rate limiting, took over customer accounts and drained stored value.
Vulnerable vs fixed code
// BAD: every attempt is checked, none is ever refused
export async function POST(request: Request) {
const { email, password } = await request.json()
const user = await verifyLogin(email, password)
// ... login logic
}// GOOD: Use a rate limiting library
import { Ratelimit } from "@upstash/ratelimit"
const limiter = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(5, "1m"), // 5 attempts per minute
})
export async function POST(request: Request) {
const ip = request.headers.get("x-forwarded-for") || "anonymous"
const { success } = await limiter.limit(ip)
if (!success) {
return NextResponse.json({ error: "Too many attempts" }, { status: 429 })
}
// ... login logic
}The identity measurement flags limits that follow a header the caller sets. Key on the network address, or on the account being attacked, and the new-identity trick no longer resets the counter.
Limits and configuration
| Measurement | Requests per endpoint | Reports when | Severity · confidence |
|---|---|---|---|
| Sequential run | up to 150, 0.05 s apart | no 429 in 150 requests; with a 429, the accepted count is reported | High · 0.85 / Info · 0.90 |
| Concurrent bursts | 10, 50, 100 at once, 5 s between | a burst holds both a 429 and a non-429 reply; stops at the first | Info · 0.90 |
| Identity switch | up to 20, then 1 | 429 for alpha, then beta is not 429 | Medium · 0.90 |
It runs only at --depth deep, with a 900 s timeout and a time budget checked between endpoints, because the three measurements can send over 300 requests to each one. It uses a plain HTTP client on purpose, not the throttled one the other scanners use. Requests carry no body and no credentials of their own, so a limiter that only counts failed logins, or only reacts to a particular payload, may never trip. A dropped connection ends the sequential loop as inconclusive (a reset or WAF throttle): no High "no rate limiting" finding is raised, and the responses received before the cut-off are reported as an Info threshold.