API Security · beginner · ~9 min
**What you will learn** - Explain *unrestricted resource consumption* and why missing rate limits is a first-class API risk, not just a denial-of-service problem. - Describe how the absence of limits amplifies brute force, credential stuffing, ID enumeration, and OTP guessing — turning probabilistic attacks into reliable ones. - Identify the common scoping models for a limiter (per-IP, per-identity, per-endpoint, global) and the exact way each one fails. - Test an endpoint *in an authorized lab* for missing or bypassable limits, and read the `429 Too Many Requests` / `Retry-After` signals correctly. - Design and **verify** a defensive fix: server-side limits, lockouts and backoff, pagination caps, correct `429` responses, and detection logging. - Recognize the token-bucket algorithm (with lazy refill) that sits behind most production rate limiters.
Security objective. The asset you are protecting is finite server capacity and the accounts, codes, and money reachable through it — CPU, database connections, an OTP that should be hard to guess, and any paid upstream call (SMS, search, LLM inference). The threat is an attacker who sends far more requests than a legitimate client ever would, in order to guess secrets, harvest data, or exhaust resources. In this lesson you will learn to detect a missing or bypassable rate limit in an authorized lab and to prevent the abuse it enables.
Every API endpoint costs something to answer: CPU to run the handler, a database round-trip, memory for the response, sometimes real money. Rate limiting is the control that caps how often a given client may spend that cost. When an API has no such cap, the weakness is called unrestricted resource consumption, and it has its own entry in the OWASP API Security Top 10 (API4:2023).
This lesson builds directly on the prerequisite The API attack surface. There you learned to enumerate the endpoints, parameters, and authentication points an API exposes. Rate limiting is the question you now ask about each of those points: "What happens if I call this a thousand times a second?" An endpoint that is perfectly safe to call once can become a breach when called without bound.
The key insight is that missing rate limiting is rarely the final vulnerability — it is a multiplier. A login endpoint that returns "wrong password" is not itself a flaw, but with no throttle it becomes a password-guessing oracle an attacker can run millions of times. A six-digit one-time password (OTP) is strong only because guessing it should take, on average, half a million tries; remove the limit and those tries become free.
Once you grasp the plain idea — cap the spend per client — the vocabulary follows: throttling (slowing a client down), lockout (temporarily blocking after repeated failures), backoff (the client waiting longer after each rejection), the token bucket (the most common limiting algorithm), and the 429/Retry-After HTTP signals that tell a client it has been throttled.
In real authorized work, rate limiting matters because it is the control that turns probabilistic attacks back into impractical ones. Many attacks work only through sheer volume:
/orders/1, /orders/2, ...) to harvest data. A limit makes this slow and noisy; its absence makes it a quiet bulk export.Because it sits underneath so many other risks, a single missing limit is the difference between "an attacker might succeed if they get lucky" and "an attacker will succeed, reliably, at scale." That leverage is why penetration testers check for it early, why bug-bounty programs pay for it, and why defenders treat it as a baseline control rather than an optional extra. As a professional you will both report missing limits (with a safe proof, not a real flood) and design the fix that closes them.
Definition. A server places no enforced cap on how frequently, or how expensively, a client may call an endpoint.
Plain explanation. Imagine a coffee shop with unlimited free refills and no one watching the urn. One polite customer is fine. One person filling a hundred thermoses drains it for everyone. An API without limits is that urn.
How it works internally. Each request consumes a slice of a finite pool — worker threads, database connections, memory, an upstream API quota, money. A limiter is a gatekeeper that counts requests per client and rejects (or delays) those over budget before they reach the expensive handler.
When to enforce vs. relax. Always enforce on authentication, password reset, OTP, search, export, and any paid upstream call. You may relax (higher limits) for cheap, cacheable, read-only public endpoints — but "relaxed" still means bounded, never absent.
Pitfall. Teams often protect the login endpoint and forget the forgot-password and verify-OTP endpoints, which are equally guessable.
A limiter must decide whom to count. The three common keys:
| Scope key | Counts by | Strong against | Weak against |
|---|---|---|---|
| Per-IP | source IP address | a single noisy machine | botnets, rotating proxies, shared NAT (blocks innocents) |
| Per-identity | account / API key | one abusive account | attacker creating many accounts; pre-auth endpoints (no identity yet) |
| Per-endpoint global | total calls to a route | total overload | lets one client starve every other client |
Best practice: combine them — limit per-identity AND per-IP, with a sensible global ceiling. On pre-auth endpoints (there is no identity yet) fall back to per-IP plus the targeted account (e.g. the username being attempted) so credential stuffing across many usernames still trips a limit.
Request in
|
v
[ key = identity? ]---> count A (per user/api-key)
[ key = IP? ]---> count B (per source IP)
|
v
over EITHER budget? --> reject 429
else --> allow, decrement both budgets
Knowledge check: An API limits logins to 5 per minute per account. Why does this barely slow down credential stuffing, where each attempt uses a different username? (Which insecure assumption — "one account = one attacker" — caused the gap?)
Definition. A bucket holds up to capacity tokens and refills at a steady rate tokens per second. Each request must take one token; if the bucket is empty, the request is rejected (or queued).
Why it is used. It allows short bursts (spend saved-up tokens) while enforcing a steady long-run average — exactly what real traffic looks like.
capacity = 10, rate = 2 tokens/sec
tokens: [##########] 10 (full)
burst of 10 requests --> all allowed
tokens: [ ] 0
next request --> 429 (empty)
wait 1 second, +2 refilled
tokens: [## ] 2 --> 2 more allowed
When NOT to use the naive version: a single in-memory bucket does not work across multiple server instances — each replica keeps its own bucket, multiplying the real limit by the number of servers. Production uses a shared store (e.g. Redis) so the count is global.
Pitfall. Refilling on a background timer can drift; the robust pattern computes refill lazily from elapsed time on each request (you will see this in the Token-bucket rate limiter exercise).
429 and Retry-AfterDefinition. When a client exceeds its budget, the correct HTTP status is 429 Too Many Requests, ideally with a Retry-After header telling the client how many seconds to wait.
Why it matters. A well-behaved client backs off on 429. The header turns guesswork into cooperation and reduces retry storms.
HTTP/1.1 429 Too Many Requests
Retry-After: 30
Content-Type: application/json
{"error":"rate_limited","retry_after_seconds":30}
Pitfall. Returning 429 but still executing the expensive work first defeats the point — reject before the handler runs.
Definition. A lockout temporarily blocks an account (or IP) after N consecutive failures; backoff makes each successive rejection wait longer (e.g. 1s, 2s, 4s, 8s).
How it differs from a plain bucket. A steady-rate bucket still lets an attacker guess continuously at the refill rate. A lockout responds specifically to failure, so a legitimate user who types one wrong password is barely affected, while a machine hammering wrong passwords is stopped cold. Real login flows use a combination: a bucket to cap raw request rate, plus failure-triggered lockout/backoff.
Pitfall. Account lockout can itself become a denial-of-service against a victim (an attacker locks you out on purpose). Prefer per-IP throttling + exponential backoff + CAPTCHA over hard permanent account locks.
Threat model (text).
Assets: user accounts, OTP codes, paid upstream quota, DB capacity, cloud $$
Entry points: /login /forgot-password /verify-otp /search /export /v1/login(legacy)
Trust boundary: internet | API gateway/WAF | app servers | DB / paid upstream
Threats crossing the boundary WITHOUT a limit:
brute force / stuffing --> accounts
short-code guessing --> OTP / 2FA
ID enumeration --> other users' objects
floods --> CPU / DB pool / wallet
Detection surface: gateway access logs, per-route 429 counts, auth-fail counts
Knowledge check (predict the output): A token bucket has capacity = 3, currently 0 tokens, rate = 1/sec. A client sends 2 requests immediately, waits 2 seconds, then sends 2 more. Which requests are allowed?
Knowledge check (detection): Which server-side log signal — visible at the trust boundary — would most cleanly reveal a credential-stuffing run against /login from a botnet, and why can that same signal produce a false positive during a legitimate traffic spike?
Knowledge check (ethics): Why must the burst-until-429 test you will write only ever run against a target you own or are authorized to test?
Rate limiting is enforced as an early middleware step, before the route handler. The shape of the check, in pseudocode:
function handle(request):
id_key = identify(request) # api-key or account id
ip_key = source_ip(request) # AND source IP (trusted proxy header)
# try to take one token from BOTH budgets; reject if either is empty
if not (buckets[id_key].consume(1) and buckets[ip_key].consume(1)):
set_header("Retry-After", seconds_until_token(id_key, ip_key))
log_rate_limit_event(id_key, ip_key) # detection signal (NO secrets!)
respond 429 # reject BEFORE doing real work
return
result = run_actual_handler(request) # only reached if allowed
respond 200 with result
Key points the syntax encodes: (1) the limiter runs first, in middleware, not inside the handler; (2) consume(1) both checks and decrements atomically; (3) rejection returns the standard 429 status plus a Retry-After header; (4) the event is logged for monitoring, with no password/OTP/token in the log; (5) both the identity and IP budgets are consulted so neither scope alone can be bypassed. The matching token-bucket math (lazy refill) appears in the Token-bucket rate limiter exercise.
Unrestricted resource consumption is its own entry in the OWASP API Top 10. Missing rate limiting amplifies almost every other attack.
429 Too Many Requests kick in.429 with a Retry-After header. Never rely on the client to throttle itself.This example follows the INSECURE -> SECURE -> VERIFY shape. It is written in Python-style pseudocode close to a real framework: a teaching reference, not a copy-paste production library. Never invent hosts or run it against anything but a local lab.
WARNING: intentionally vulnerable — use only in a local, isolated, authorized lab. Do not deploy.
# No limit: every request reaches the password check, unbounded.
def login(account_id, password):
if check_password(account_id, password):
return response(200, {"token": issue_session(account_id)})
return response(401, {"error": "invalid_credentials"})
Why it is unsafe: an attacker can call this endpoint millions of times per hour, turning it into a free password-guessing oracle and a denial-of-service vector. Nothing counts the attempts, nothing rejects them, and nothing logs the abuse.
import time
class TokenBucket:
"""Allows short bursts up to `capacity`, long-run average = `rate`/sec."""
def __init__(self, capacity, rate):
self.capacity = capacity
self.rate = rate
self.tokens = capacity # start full
self.last = time.monotonic() # last refill timestamp (monotonic!)
def consume(self, cost=1):
now = time.monotonic()
elapsed = now - self.last
# lazy refill: add tokens for elapsed time, cap at capacity
self.tokens = min(self.capacity, self.tokens + elapsed * self.rate)
self.last = now
if self.tokens >= cost:
self.tokens -= cost
return True # allowed
return False # over budget -> caller returns 429
# One bucket per account id AND one per source IP. New keys start full.
account_buckets = {}
ip_buckets = {}
def bucket_for(store, key, capacity, rate):
if key not in store:
store[key] = TokenBucket(capacity, rate)
return store[key]
def login(account_id, password, source_ip):
# 5 attempts burst, refilling ~1 per 12s (~5/minute) per account;
# a looser but present per-IP ceiling catches multi-account stuffing.
acct = bucket_for(account_buckets, account_id, capacity=5, rate=1/12)
ip = bucket_for(ip_buckets, source_ip, capacity=20, rate=1/6)
if not (acct.consume(1) and ip.consume(1)):
# Reject BEFORE checking the password -> no brute-force oracle.
log_event("rate_limited", account=account_id, ip=source_ip) # no password!
return response(429, {"error": "rate_limited"}, retry_after=12)
if not check_password(account_id, password):
log_event("login_failed", account=account_id, ip=source_ip) # no password!
return response(401, {"error": "invalid_credentials"})
return response(200, {"token": issue_session(account_id)})
What it does. Each account gets a bucket of 5 attempts refilling at one every 12 seconds; each source IP gets a looser bucket (20 refilling at one every 6s) so an attacker rotating usernames from one IP still trips a limit. The first 5 rapid attempts for an account are allowed (burst); the 6th within that window is rejected with 429 and Retry-After: 12. Crucially the limiter runs before the password check, so an attacker cannot brute-force faster than the bucket allows.
Expected behaviour. Five quick wrong passwords return 401. The sixth quick attempt returns 429. After waiting ~12 seconds, one more attempt is allowed.
# Runs against your OWN local lab endpoint only. Proves the fix REJECTS bad
# input (excess requests) and ACCEPTS good input (one request after backoff).
def verify_login_limit(lab_url):
saw_429 = False
for i in range(6): # small, bounded burst - do not flood
r = post(lab_url, {"account": "alice", "password": "wrong"})
if r.status == 429:
saw_429 = True
assert "Retry-After" in r.headers, "FAIL: 429 without Retry-After"
break
assert saw_429, "FAIL: burst never rejected -> limit missing"
time.sleep(int(r.headers["Retry-After"])) # honor the backoff
r2 = post(lab_url, {"account": "alice", "password": "correct-lab-pw"})
assert r2.status == 200, "FAIL: good request rejected after wait"
print("PASS: rejects excess, accepts one valid request after Retry-After")
Expected output: PASS: rejects excess, accepts one valid request after Retry-After. If instead you never see a 429, the limit is missing or scoped wrong. Edge cases to remember: (1) a multi-server deployment needs a shared store instead of the in-memory dicts, or each replica enforces its own limit; (2) time.monotonic() is used (not wall-clock) so refill cannot be skewed by clock changes; (3) per-account scoping alone does not stop stuffing across many accounts — that is exactly why the per-IP bucket is also present.
Walk through the secure login flow for an attacker sending 6 rapid wrong-password attempts for account alice from one IP.
bucket_for(account_buckets, "alice", 5, 1/12) finds no entry, creates a TokenBucket with tokens = 5; the IP bucket is created with tokens = 20.acct.consume(1) — elapsed is near zero, refill adds ~0; tokens (5) >= 1, so tokens -> 4, returns True. ip.consume(1) -> 19, True. Both pass.log_event("login_failed", ...) (no password logged) -> 401.401.acct.consume(1): barely any time elapsed, refill ~= 0, tokens = 0 which is **<** 1 -> returns False. The and short-circuits, the handler logs "rate_limited" and returns 429 with Retry-After: 12. The password is never checked.Trace table (assume calls 1–6 happen within one second, account bucket only):
| Call | tokens before | elapsed | tokens after refill | consume? | response |
|---|---|---|---|---|---|
| 1 | 5 | 0.0 | 5 | yes -> 4 | 401 |
| 2 | 4 | 0.1 | ~4 | yes -> 3 | 401 |
| 3 | 3 | 0.1 | ~3 | yes -> 2 | 401 |
| 4 | 2 | 0.1 | ~2 | yes -> 1 | 401 |
| 5 | 1 | 0.1 | ~1 | yes -> 0 | 401 |
| 6 | 0 | 0.1 | ~0 | no | 429 |
consume computes elapsed ~= 12, refill = 12 * (1/12) = 1 token -> tokens ~= 1 -> one more attempt is allowed. That is exactly the steady ~5-per-minute long-run rate, while still permitting an initial burst of 5.Verify script trace. verify_login_limit loops posting wrong passwords; on the 6th it receives 429, sets saw_429 = True, asserts the Retry-After header exists, and breaks. It sleeps for Retry-After seconds, then posts the correct lab password and asserts 200. Both assertions passing prints the PASS line — proving the fix rejects the excess burst and accepts a valid request once the client has backed off.
Mistake 1 — Limiting on the client side. Some teams disable the Login button in the front end and call it rate limiting.
Wrong: // browser disables the Login button for 30s after 5 tries. This is cosmetic — an attacker calls the API directly with curl and ignores the UI. Correct: enforce in server middleware (see the secure login). Recognize it by testing the raw endpoint, not the UI.
Mistake 2 — Checking the password before the limit. Running the sensitive work first leaks a timing oracle and wastes resources. Wrong order: check_password(...) then if over_limit: 429. Correct order: if not consume(1): return 429 then check_password(...). Reject before any real work. Recognize it: on a rejected request, the login_failed log should NOT appear — only rate_limited.
Mistake 3 — Per-IP only. A single per-IP limit blocks honest users behind shared NAT (offices, mobile carriers) while a botnet with thousands of IPs sails through. Fix: combine per-identity and per-IP, and add a per-username bucket on pre-auth routes.
Mistake 4 — Forgetting sibling endpoints. Protecting /login but leaving /forgot-password, /verify-otp, or a v1 legacy route unprotected. Attackers pivot to the unguarded twin. Fix: enumerate every endpoint that performs the sensitive action (a habit from The API attack surface) and limit each.
Mistake 5 — In-memory bucket across many servers. With 4 replicas and a 5/min bucket each, the real limit is 20/min. Fix: use a shared counter store (Redis), or divide the budget by replica count, and load-test the aggregate limit.
Mistake 6 — Trusting a spoofable client header. Keying the limit on X-Forwarded-For when the client can set it lets an attacker mint a fresh limit per request by changing the header. Fix: only trust the forwarded-IP header when it is written by your proxy, and strip/overwrite any client-supplied copy at the edge.
When a limiter "doesn't work," check these in order:
identify() returns the real per-client key. If using X-Forwarded-For, make sure your trusted proxy sets it and the client cannot spoof it.last, or used wall-clock time that jumped backward. Use a monotonic clock and update last on every consume.429 but work still happens. If your bills/logs show the expensive operation running on rejected requests, the rejection is after the work. Move the check before the handler.Questions to ask when it fails (authorized lab only): Does a burst eventually hit 429? Is there a Retry-After? Can I bypass by changing IP, adding/removing X-Forwarded-For, switching API version, or calling a sibling endpoint? Does the limit reset too quickly? Are rejected requests still being logged as rate_limited (proving the check ran first)?
Security & safety
Authorization & ethics. Only test rate limiting against systems you own or are explicitly authorized to assess — your own lab, a CTF target, or an intentionally vulnerable container on localhost. Hammering a third-party API is itself a denial-of-service attack and is illegal without written permission. Stop at the first sign the limit is missing — you never need to actually exhaust a real service to prove the finding. Decoding a returned token or reading a 429 body is not the same as compromising the system; report what you safely observed, no more.
Authorization checklist (before any burst test):
[ ] I own the target OR have signed/explicit written authorization for it
[ ] The target is a local/isolated lab, container, VM, or in-scope CTF host
[ ] Scope, endpoints, and time window are agreed and I am inside them
[ ] My burst is small and bounded (stop on first 429) - no real flood
[ ] I log only non-secret fields and will delete lab data after
Detection & logging. Log every rate-limit decision so abuse is visible at the trust boundary. Record: timestamp, source IP, account id or hashed API key, endpoint/route, the security decision (allowed / rate_limited / login_failed), and a correlation id to stitch a request across services. Never log passwords, OTP codes, session cookies, bearer tokens, private keys, full card numbers (PANs), or unneeded PII — use placeholders like API_KEY=<development-placeholder> in examples. Signals that indicate abuse: a burst of 429s from many IPs against /login (credential stuffing); a single account or IP hitting the limit repeatedly (targeted brute force); rising login_failed across many usernames (spraying). False positives arise from legitimate spikes — a mobile app retry storm after an outage, a shared corporate NAT IP, or a marketing push — so alert on sustained/anomalous patterns and correlate with success rates, not a single 429.
Mitigation verification. In your lab, script a small burst and assert requests beyond the budget return 429 with Retry-After; confirm the password check does not run on rejected requests (no login_failed for them); re-run after Retry-After seconds and confirm exactly one attempt is allowed; then rotate the source IP and confirm the per-account limit still holds. This is the verify_login_limit pattern from the code section.
Misconceptions to avoid. Passing an automated scanner does not prove an endpoint is rate-limited — scanners often send too few requests to trip a limit; test the burst yourself. And nothing is "completely secure": a rate limit raises the cost of guessing, it does not make guessing impossible.
Lab cleanup / reset. After testing: restart the lab service (or docker compose down && up) to clear in-memory buckets and lockouts; delete any test accounts and captured logs; revoke any tokens issued during the test; and confirm the environment is back to a clean baseline before the next run.
Concrete authorized use. A tester engaged to assess a client's login API confirms — within the agreed scope — that /login returns 429 after a small burst but /forgot-password never does. They file a finding with a safe proof (a 6-request burst screenshot, not a flood), rate the severity by exploitability and impact, and recommend the per-identity + per-IP fix. The defending team implements it and re-tests with the verification script above. Public APIs from GitHub, Stripe, and Cloudflare advertise limits and return 429 with headers such as Retry-After and X-RateLimit-Remaining; login and OTP flows use lockout/backoff; AI/LLM gateways meter requests and tokens because each call costs real money (the canonical denial-of-wallet concern).
Professional best practices
Beginner rules:
login, forgot-password, and verify-otp.429 with a clear Retry-After; reject before doing the expensive work.Advanced rules:
429 rates, top offenders, and alerts on stuffing patterns — without logging secrets.429/503 with Retry-After) over letting the database fall over. Avoid hard account lockouts that an attacker can weaponize against a victim; prefer IP throttling + backoff + CAPTCHA.Beginner 1 — Read the signal. Given this response, explain in plain language what happened and what a well-behaved client should do next:
HTTP/1.1 429 Too Many Requests
Retry-After: 20
Requirements: state the status meaning, what Retry-After: 20 instructs, and one reason returning this is better than silently dropping the request. Concepts: 429, Retry-After, backoff.
Beginner 2 — Spot the gap. You are given an API (in an authorized lab) with these endpoints: /login, /forgot-password, /verify-otp, /health. /login returns 429 after 5 rapid calls; the others never do. Objective: list which endpoints are dangerously unprotected and explain the specific attack each one enables. Hint: think about what each endpoint guesses or sends. Concepts: scope, sibling endpoints, OTP brute force. Defensive conclusion: state the fix for each gap and how you would verify it.
Intermediate 1 — Trace a bucket. A token bucket has capacity = 4, rate = 1 token/sec, starts full. A client sends requests at t=0 (x4), t=0.5 (x1), t=3 (x2). Objective: produce a trace table of tokens-before, refill, allowed?/response for each request. Constraint: use lazy refill. Concepts: token bucket, lazy refill, burst vs. steady rate.
Intermediate 2 — Design the scope. Design a limiting scheme for a /search endpoint that is expensive (hits a search cluster) and reachable both by logged-in users and anonymous visitors. Requirements: specify the scope key(s), separate budgets for authed vs. anonymous, and the response on rejection. Justify why per-IP alone or per-account alone is insufficient here, and name the two log fields you would emit on rejection (no secrets). Concepts: per-identity + per-IP, expensive endpoints, global ceiling, detection.
Challenge — Verify a fix in the lab. Using only a local, intentionally vulnerable target you control, design a short test that (a) sends a small burst until it observes 429, (b) asserts a Retry-After header is present, (c) waits that long and confirms exactly one further request succeeds, and (d) confirms the per-account limit is not bypassed by changing the source IP. Requirements: the test must stop as soon as the limit is confirmed (do not flood), log only non-secret fields, and print a pass/fail summary. Constraints: authorized lab only; no third-party targets; complete the authorization checklist first and run the cleanup steps after. Concepts: mitigation verification, detection/logging, scope-bypass testing. (Do not write a full solution — design the steps yourself, then remediate any gap you find and re-verify.)
Main concepts. Unrestricted resource consumption — missing or absent rate limiting — is a first-class API risk (OWASP API4) and a force-multiplier for brute force, credential stuffing, ID enumeration, OTP guessing, and denial-of-service / denial-of-wallet. A limiter caps the spend per client; the token bucket (capacity + steady refill, computed lazily) is the standard algorithm, allowing bursts while bounding the long-run average.
Most important mechanics. Enforce limits server-side, before the handler runs. Scope per-identity AND per-IP (plus per-username on pre-auth routes) with a global ceiling, and share the counter across replicas so the limit is aggregate. Return 429 Too Many Requests with Retry-After, add lockout/backoff on auth, and cap pagination and expensive operations.
Common mistakes. Client-side-only limits; checking the password before the limit; per-IP-only scoping; forgetting sibling endpoints like /forgot-password and /verify-otp; per-instance in-memory buckets that multiply the real limit; and trusting a spoofable X-Forwarded-For.
What to remember. Test authorized targets only and stop at first proof; reject before doing real work; verify the fix (reject excess, accept one after backoff, no bypass by IP change); log the security decision but never secrets; a scanner passing does not prove a limit exists, and no limit makes guessing impossible — it only makes it impractical.