API rate limiting: how to test a login limit

To test API rate limiting on a login endpoint, send wrong passwords until you get a 429, then repeat the run with a changed client key, such as a new X-Forwarded-For value on each request. If the 429 disappears, the client controls the limit. In our local fixture, five wrong passwords sent with one claimed address were followed by a 429 Too Many Requests on the sixth. Twenty requests with a different header value each got no 429, and the twentieth sent the correct password and logged in. Keying the limit on the targeted account plus the real peer address stopped the same twenty requests at the sixth, with a Retry-After header.

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

What API rate limiting is and what a test must find

API rate limiting means the server counts requests per key in a time window and refuses requests above a limit. The usual refusal is 429 Too Many Requests. RFC 6585 says the code indicates that the user has sent too many requests in a given amount of time, and that the response may include a Retry-After header. The RFC does not define how the server identifies the user or counts requests. We call that identity the key. If the client can change the key, the client can reset the count.

A test must show that the limit fires, and then check whether the client can change the key. This fixture shows the second failure. It relates to OWASP API4:2023, Unrestricted Resource Consumption, which advises limiting how often a client can interact with the API and throttling how many times a single client can run one operation, such as validating an OTP. When the client can reset the count, you have a rate limit bypass.

Define the scope of the fixture

  • Target: short-lived HTTP servers bound to IPv4 loopback, 127.0.0.1, on ports the operating system picks. One endpoint: POST /login.
  • Policy: at most 5 attempts per key in a 60-second window. The sixth attempt gets 429.
  • Accounts: alice and bob, with public demo passwords. Nothing else is stored.
  • Header values: 203.0.113.7 and 198.51.100.1 to 198.51.100.20. These are documentation addresses reserved for examples.
  • Requests: 56 curl requests in total, spread over five fresh servers.

The vulnerable mode is insecure on purpose, so the fixture binds only to loopback. To test a real system you need written permission, an agreed list of hosts and test accounts, and a cap on the number of attempts. The Acceptable Use Policy describes authorized use. This article gives no permission to test another system.

Run the downloadable fixture

Download fixture.py and verify.py into one directory. You need Python 3.9 or later and curl. Python uses only its standard library. Read the source, then run:

python3 verify.py --output rerun

The output directory must not exist. The runner starts each server on a free port, sends the requests with curl (--noproxy '*', a one-second connect timeout, a three-second total timeout), saves every raw response and stops the servers. Our recorded command was python3 verify.py --output recorded-run. It exited 0 with {"checks": 56, "passed": true, "failures": []}. Passed means every response matched what the experiment expected, including the intentional bypass.

The run used fixture version 1.0.0, Python 3.15.2 and curl 8.7.1 on macOS ARM64, starting at 2026-11-10 22:46 UTC. A rerun gives different ports, dates and timing. Hashes and exact versions are in results.json.

Observed results: the limit, the bypass, the fix

Each scenario ran against a fresh server, so no earlier request counted against the next one. All wrong guesses were sent for alice. In the rotating scenarios the header value changed on every request, and the TCP peer stayed 127.0.0.1.

Observed HTTP status codes by request number
ScenarioModeRequestsObserved statuses
Same header on every requestVulnerable6 wrong passwords1-5: 401. 6: 429, no Retry-After
Header rotated on every requestVulnerable19 wrong, then the correct password1-19: 401. 20: 200, logged in as alice
Same header on every requestFixed6 wrong passwords1-5: 401. 6: 429, Retry-After: 60
Header rotated on every requestFixed19 wrong, then the correct password1-5: 401. 6-20: 429, Retry-After: 60. The correct password on request 20 also got 429
Bob logs in right after the rotationFixed1 correct password200
Alice correct, alice wrong, bob correctFixed, fresh server3 requests200, 401, 200

The first row shows that the limiter works against a client that keeps one address. The second row is the finding. The same limiter never fired when the header changed, and the attacker had a valid login after 20 attempts. In fixed mode the limit held under the same rotation. The last two rows are positive controls. Legitimate logins still succeed, and throttling alice did not block bob, because the key includes the account.

Verify the bypass before you report it

A 200 on the last request does not prove the bypass by itself. First confirm that the limit exists: the same-header run returned 429 on request 6. Then confirm that only the header changed: the peer address was the same loopback client throughout. Last, confirm the effect: the rotating run returned no 429 and the server accepted the correct password. This is the exact command for the final request of the vulnerable rotation, on the port of the recorded run:

curl --noproxy '*' --silent --show-error --include \
  --http1.1 --connect-timeout 1 --max-time 3 --request POST \
  --header 'Content-Type: application/json' \
  --data '{"username": "alice", "password": "demo-passphrase-alice"}' \
  --header 'X-Forwarded-For: 198.51.100.20' \
  http://127.0.0.1:53059/login

The saved response is HTTP/1.0 200 OK with the body {"status": "ok", "user": "alice"}. Compare it with the 429 from the same-header run, which carries no Retry-After header.

A useful report names the endpoint, the header that controls the key, the limit that the test showed, the number of requests sent, the account used and the outcome. Say how many attempts you made. Use your own test accounts. Do not lock out real users, and stop at the agreed cap. Here the impact is that an online password guess is not slowed. The experiment does not rate the severity for any real system, which depends on password strength, other controls and what the account can reach.

Fix the key and retest

The fixed mode builds the key from the account named in the request and the address of the TCP peer. It reads X-Forwarded-For only when the peer is a configured proxy, and the refusal carries Retry-After. These excerpts come from fixture.py:

def real_client_address(peer, forwarded_for, trusted=TRUSTED_PROXIES):
    """Use X-Forwarded-For only when the TCP peer is a known proxy."""
    if peer not in trusted or not forwarded_for:
        return peer
    for hop in reversed([h.strip() for h in forwarded_for.split(",")]):
        if hop not in trusted:
            return hop
    return peer

# in Handler.do_POST:
else:
    # Fix: key on the targeted account plus the real peer address.
    key = (username, real_client_address(peer, forwarded))
wait = self.server.limiter.hit(key)
if wait:
    headers = {"Retry-After": str(wait)} if self.server.mode == "fixed" else {}
    return self.reply(429, {"error": "too_many_requests"}, headers)

The code walks the header from the right and skips known proxies, because a client controls the left side of that list. In the fixture TRUSTED_PROXIES is empty, so the header is ignored.

The retest used the same 20 requests on a new server, port 53088. Requests 1 to 5 returned 401. Requests 6 to 20 returned 429 with Retry-After: 60, including request 20, which carried the correct password. With the fix, a new header value does not give a new key. Our runner also required a numeric Retry-After on every fixed-mode 429 and none on the vulnerable one.

Inspect the evidence bundle

Every password, account and address in the bundle is synthetic. The response files contain the full HTTP text, including dates and server headers.

What this experiment does not cover

The fixture is a toy: one in-memory fixed-window counter in one process. We did not test a limit shared between servers, window expiry, bursts around a window boundary, IPv6, other forwarding headers such as Forwarded or X-Real-IP, or a real reverse proxy. The trusted-proxy branch exists in the code, but the fixture has no proxy and the run never executed it.

The fixed key also leaves a gap. An attacker with many real addresses can send five guesses per address against one account, because each pair of account and address gets its own count. Defenses against that, such as an account-wide limit, add lockout costs for the account owner and need a decision from the application owner. Our table shows only that rotating a header no longer works.

Related reading: authorization testing for APIs covers the ownership checks that sit beside limits like this one. Brute force against one-time codes is the case in 2FA bypass testing, and parallel requests can beat a counter as shown in race condition testing. The wider list is in the OWASP API Top 10 overview.

Excession Labs runs this kind of rate-limit and abuse testing in its security assessments, against the hosts and accounts the system owner approves. See how a finding like this looks in a sample report.