Chapter 40B — API rate limiting in depth
In one sentence
Rate limiting is not one control but a stack of decisions — which algorithm, which dimension, where it's enforced, and what happens when the limiter itself is under attack — and getting any layer wrong turns a rate limit from a real control into a false sense of one.
Where we are
Part 5 Ch 28 covered rate limiting as one piece of resilience. This chapter goes deeper on the API-specific mechanics: the algorithms, the dimensions that actually stop abuse (not just DoS), and the bypass techniques attackers use against real, production rate limiters.
How it works
The algorithms, and their trade-offs:
FIXED WINDOW count requests in a fixed clock interval (e.g. per
minute); simplest, cheapest -- but allows up to 2x
the intended rate at a window boundary (a burst
just before AND just after the reset).
SLIDING WINDOW LOG store every request's timestamp, count how many
fall in the trailing window; accurate, but memory
cost grows with request volume.
SLIDING WINDOW
COUNTER approximates the sliding log cheaply by weighting
the current and previous fixed windows -- the
common, practical middle ground.
TOKEN BUCKET a bucket refills at a steady rate and holds up to
a burst CAPACITY; a request consumes a token, is
rejected if the bucket is empty. Naturally allows
controlled bursts while capping the sustained rate.
LEAKY BUCKET requests queue and are processed at a fixed
outflow rate -- smooths bursts into a steady stream
rather than allowing them.
GCRA (Generic Cell
Rate Algorithm) a mathematically equivalent, more memory-
efficient way to implement token-bucket-like
behaviour with a single stored value (a "theoretical
arrival time") per key instead of a bucket state --
this is what Cloudflare and several API gateways
use at scale specifically because it's cheap to
store per-key in a distributed cache.
The dimensions that actually matter for an API (not just "per IP"):
PER API KEY / CLIENT ID the primary dimension for an authenticated
API -- ties abuse to an accountable, revocable identity rather than
an IP address that's trivially rotated or shared (a corporate NAT,
a mobile carrier's CGNAT).
PER USER / ACCOUNT independent of which key/device they used --
catches an attacker who spreads requests across multiple API keys
registered to the same compromised account.
PER ENDPOINT + METHOD a login endpoint and a product-listing
endpoint have wildly different legitimate request rates; ONE global
limit either starves the busy, harmless endpoint or leaves the
sensitive one wide open.
PER TIER/PLAN free/pro/enterprise customers get
different limits -- both a product decision and a security one (a
free-tier account is a cheaper foothold for an attacker, so it
deserves a TIGHTER limit on sensitive actions).
PER IP still worth keeping as a coarse BACKSTOP
(catches unauthenticated abuse before an API key even exists, e.g.
against a public signup or login endpoint) -- but never the ONLY
dimension for anything authenticated.
BUSINESS-ACTION SPECIFIC independent of raw request volume: "how
many password reset emails can this account trigger per hour,"
"how many times can this coupon code be checked," "how many
purchase attempts per session" -- this is OWASP API6:2023's
"unrestricted access to sensitive business flows" (Ch 40E), and it
needs its OWN limit dimension distinct from general request-rate
limiting.
Where it's enforced, and the response contract:
EDGE/CDN volumetric, coarse, cheap -- stops the largest,
least-targeted abuse before it reaches your infrastructure at all.
API GATEWAY per-key/per-plan/per-endpoint enforcement (Kong,
Apigee, AWS API Gateway usage plans, Azure APIM) -- the layer that
actually understands "who is this client" via the API key/OAuth
token.
DISTRIBUTED, APPLICATION-LEVEL a shared Redis-backed counter (an
atomic `INCR`+`EXPIRE`, or better, a single Lua script so the
check-and-increment is ATOMIC -- a naive check-then-increment across
two Redis round trips is itself a Ch 6-Ch-35-style race condition:
enough parallel requests can all read the pre-increment count before
any of them writes it back) -- needed for limits that must be
consistent across MANY service replicas, not just one gateway
instance. Envoy's rate-limit service (a centralised gRPC service
every proxy instance calls) is the standard pattern at real scale.
RESPONSE: `429 Too Many Requests` with a `Retry-After` header, PLUS
the informational headers so well-behaved clients back off correctly:
`RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`
(the current IETF draft standard), or the long-standing de facto
`X-RateLimit-Limit` / `X-RateLimit-Remaining` / `X-RateLimit-Reset`
convention (GitHub's, Stripe's, and many other public APIs' actual
header names) -- pick one convention and be consistent; don't do the
expensive part of the request BEFORE checking and rejecting.
FAIL-OPEN VS FAIL-CLOSED on limiter-store outage: the SAME decision
as Part 5 Ch 28 -- lean fail-closed (with a cheap LOCAL fallback limit
that works without the shared store) for auth/OTP/password-reset
endpoints specifically, fail-open with an alert for general read
traffic.
Rate-limit bypass techniques — know these, because attackers use them routinely against real APIs:
IP ROTATION residential-proxy networks or a botnet spread a
credential-stuffing or scraping attack across thousands of source
IPs, each individually well under any per-IP threshold. -> defence:
per-account/per-credential-attempted limiting (count FAILED LOGINS
for a given username regardless of source IP), not per-IP alone.
HEADER SPOOFING if the rate limiter naively trusts a client-
supplied `X-Forwarded-For` header as "the real client IP" (rather
than the value your OWN load balancer appended), the attacker just
sets a new fake IP on every request and resets their own limit
every time. -> defence: only trust `X-Forwarded-For`/`X-Real-IP`
from your own trusted edge, and strip/overwrite anything a client
sent for that header before it reaches the limiter (G1/Part 2's
"don't trust client-supplied forwarded headers" rule, applied here).
PATH/CASE VARIATION `/api/users`, `/API/users`, `/api/users/`, and
`/api//users` may be treated as DIFFERENT rate-limit keys by a naive
limiter keyed on the raw, unnormalised path -- multiplying the
effective limit by however many equivalent forms exist. -> defence:
normalise the path BEFORE using it as a rate-limit key.
PARALLEL / RACE-CONDITION BYPASS fire many requests at once (Ch 35's
single-packet technique applies directly here) so they all read the
pre-increment counter value before any of them commits their
increment -- a genuine, well-documented bug-bounty pattern used to
brute-force OTP/2FA codes and password-reset tokens against major
consumer platforms by racing dozens of guesses through a
check-then-increment limiter faster than it could commit each one.
-> defence: an ATOMIC check-and-increment (a single Redis Lua
script, or a database `UPDATE ... WHERE count < limit` with a
rows-affected check, Ch 35) -- never a separate read then write.
IDENTITY CYCLING create a new account / new API key / new OAuth
client registration to get a fresh limit once the old one is
exhausted. -> defence: limits keyed on signals that survive identity
rotation where possible (device fingerprint, payment instrument, a
CAPTCHA/proof-of-work gate on account/key creation itself -- this is
exactly where WAAP's bot-management layer, Ch 40D, adds a dimension
a pure rate limiter can't).
SLOW-AND-LOW stay just under the threshold, indefinitely,
across a long time horizon -- individually invisible to any
short-window limiter. -> defence: longer-window, cumulative anomaly
detection (Part 8's UEBA/DAM-style baselining) layered ABOVE simple
rate limiting, not as a replacement for it.
Worked example
A real, well-documented enumeration failure, and the specific dimension that would have stopped it.
In 2013-2014, security researchers publicly demonstrated that
Snapchat's "Find Friends" API endpoint -- designed to match a
submitted phone number to a Snapchat username -- had NO effective
rate limit or abuse detection tying repeated lookups to a single
requester at meaningful scale. Automated queries were able to work
through very large blocks of phone numbers and harvest the matching
usernames. The result, after Snapchat's initial slow response, was a
published database of roughly 4.6 million Snapchat usernames mapped
to phone numbers.
THE MISSING DIMENSION: a lookup-style endpoint like this needs a
rate limit keyed on THE REQUESTING CLIENT/SESSION *and* on the
ENUMERATION PATTERN ITSELF (a rapidly increasing sequence of phone
numbers from one caller, or the same caller making tens of thousands
of distinct single-target lookups in a short window) -- not just a
generic per-IP or per-minute cap that a slow, distributed, or
patient enumeration comfortably stays under.
THE FIX PATTERN (equally applicable to any "look up one user by
ID/phone/email" endpoint you own):
- a strict per-account/per-session limit on TOTAL distinct lookups
per day, not just per minute (catches the slow-and-low case).
- CAPTCHA/proof-of-work after a modest number of lookups, escalating
with volume (a WAAP bot-management capability, Ch 40D).
- return the SAME response shape whether a number matches or not,
with added LATENCY, so the endpoint itself isn't a cheap oracle
(the enumeration-response-uniformity principle from G1 Ch 10,
applied to a lookup API rather than a login form).
- alert on the ENUMERATION PATTERN specifically (a single
caller/session touching a monotonically increasing or otherwise
systematic set of identifiers) as a Part 8-style detection, not
just a raw volume threshold.
Real-world scenario 2: racing a rate limiter to brute-force a one-time code
One of the most widely-cited bug-bounty write-ups in this exact space
(independently reproduced against more than one major consumer
platform over the years) demonstrates the Ch 40B "parallel/race-
condition bypass" technique in full: a password-recovery flow sends a
SIX-DIGIT numeric code and rate-limits GUESSES against it -- but the
limiter's counter increments AFTER a guess is checked, not atomically
WITH the check. By firing a very large number of guesses in PARALLEL
(spread across many source IP addresses using a modest cloud-compute
budget, so no single IP's request rate looked anomalous either), a
researcher was able to test on the order of tens of thousands of
candidate codes -- comfortably covering the full one-million-value
space of a 6-digit code -- inside the short validity window of the
code itself, before the rate limiter's sequential counter could
catch up and actually start rejecting requests.
WHY THIS WORKED, MAPPED TO THE CHAPTER:
- the check-then-increment pattern is EXACTLY Ch 35's race-condition
root cause, applied to the rate limiter's OWN internal state
rather than to application data -- the limiter itself had a
TOCTOU bug.
- per-IP distribution defeated any IP-based dimension entirely (the
"IP rotation" bypass class from this chapter).
- a 6-digit numeric space is small enough that "guess quickly in
parallel" is a completely tractable brute force ONCE the rate
limit is bypassed -- the limiter was doing ALL of the real
protective work, and it had exactly one structural flaw.
THE FIX, restated precisely for THIS shape of endpoint:
- an ATOMIC conditional decrement (`UPDATE attempts SET remaining =
remaining - 1 WHERE code_id = ? AND remaining > 0`, checking rows-
affected) so concurrent guesses cannot all read the same
pre-decrement value.
- a per-code-identity limit (tied to the specific reset request/
session that generated the code), not just a general per-IP or
per-account rate limit, so parallel guesses from many IPs still
all count against the SAME small budget.
- a LONGER, higher-entropy code (or a short EXPIRY plus exponential
backoff after only a handful of failures) shrinks the brute-
forceable space enough that even a race-condition bypass of the
counter doesn't yield enough attempts to matter within the code's
validity window.
Real-world scenario 3: a mature positive design — cost-based limiting for a flexible query API
Not every lesson here is a breach. GitHub's public GraphQL API
documentation describes a RATE-LIMITING MODEL specifically designed
for the Ch 40B problem that a flat per-request limit can't solve:
a GraphQL query's COST varies enormously depending on what it asks
for (a query touching one field is trivial; a deeply nested query
pulling thousands of connected objects can be enormously expensive)
-- so a simple "N requests per hour" limit either starves cheap
queries or lets one expensive query do the work of thousands.
GitHub's documented approach assigns each query a calculated POINT
COST (based on the number of nodes/connections it touches) BEFORE
executing it, and rate-limits against a POINT BUDGET per hour rather
than a request COUNT -- a client can make many cheap queries or a few
expensive ones, but the total WORK done against the backend per
window is what's actually bounded. The response headers expose the
client's point cost, remaining budget, and reset time (the same
`X-RateLimit-*` convention pattern this chapter recommends), so a
well-behaved client can self-throttle before ever hitting a 429.
WHY THIS IS WORTH STUDYING AS A POSITIVE PATTERN: it's the practical,
production answer to Ch 40A's GraphQL cost-limiting note and this
chapter's "one global limit for every endpoint" common mistake --
applied at real scale, by an API whose entire value proposition is
letting clients ask for exactly the data shape they need. If your own
API has GraphQL, or any endpoint whose cost varies wildly by request
shape (a search endpoint with optional deep filters, a report
generator with configurable scope), a flat per-request counter is the
wrong tool; a computed-cost budget is the right one.
Practice (75 min)
- Implement an ATOMIC token-bucket rate limiter in Redis using a single Lua script (check-and-decrement in one round trip). Load-test it with 100 concurrent requests at the limit boundary and confirm the count is exact — then implement the SAME logic as a naive read-then-write two-step and show the race lets more requests through than the limit allows.
- Add per-account (not just per-IP) failed-login tracking to a lab login endpoint; simulate a credential-stuffing attempt spread across 20 fake source IPs against ONE username and confirm the per-account limit still catches it.
- Deliberately misconfigure a limiter to trust client-supplied
X-Forwarded-For; demonstrate resetting your own rate limit by sending a new fake value on each request; then fix it to only trust your edge's appended value. - Build a business-flow-specific limit (Ch 40E's API6) distinct from your general API rate limit — e.g. "max 3 coupon-code attempts per order" — enforced atomically, independent of the endpoint's general per-minute cap.
- Add the
RateLimit-*(orX-RateLimit-*) response headers to a lab API and confirm a client can read its remaining quota and back off correctly before hitting 429.
Across the series
- Token-bucket limiting keyed by identity, with
Retry-After, built in Go: HTTPS guide, Chapter 21. All five rate-limiting algorithms compared: the Go plan's Day 92, plus CIDR-aware limiting on Day 93. - Why per-IP limits fail behind NAT and CGNAT: OSI guide, Layer 3.
Common mistakes
- Per-IP as the only dimension. Trivially defeated by rotation/proxies for anything authenticated; keep it only as a coarse, unauthenticated backstop.
- Trusting client-supplied forwarded-IP headers. Lets an attacker reset their own limit at will.
- Check-then-increment across two operations. A race condition in the limiter itself, exploitable with the same parallel-request techniques as Ch 35.
- One global limit for every endpoint. Starves high-volume legitimate endpoints or under-protects sensitive ones; scope per endpoint and per business action.
- No limit on TOTAL volume over a long window. Catches bursts, misses slow-and-low abuse entirely.
- Rate limiting as the only defence against enumeration/scraping. Add response-uniformity, added latency, and pattern-based detection alongside it — not instead of it.
Check yourself
- Token bucket vs leaky bucket — what does each do to a burst of requests?
- Why is GCRA attractive for a high-scale, distributed rate limiter?
- Give four dimensions to rate-limit on beyond per-IP, and why per-IP alone is insufficient for an authenticated API.
- Describe the race-condition bypass against a naive rate limiter and the fix.
- Why does trusting a client-supplied
X-Forwarded-Forheader break rate limiting, and what's the fix? - In the Snapchat "Find Friends" example, what dimension of rate limiting was missing, and what would have caught the pattern regardless of request volume per minute?
Further reading
- Algorithm: Cloudflare's engineering blog posts on GCRA-based rate
limiting at scale; the
throttledand Kong rate-limiting plugin documentation for reference implementations. - Standard: IETF draft "RateLimit header fields for HTTP"
(
datatracker.ietf.org); GitHub's and Stripe's public API rate-limit header documentation for the de facto convention. - History: the 2013-2014 Snapchat "Find Friends" disclosure coverage (Gibson Security's original report and the subsequent press coverage of the SnapchatDB leak) — a clear, well-documented case study in missing enumeration-abuse limits.
- Technique: Laxman Muthiyah's published bug-bounty write-ups on brute-forcing password-recovery codes by racing a rate limiter across distributed source IPs — search his blog for the account-recovery / OTP-brute-force posts; among the most widely cited demonstrations of this exact bypass class.
- Design pattern: GitHub's public GraphQL API documentation on its point-based (cost-based) rate-limiting model — a real, current reference for cost-aware limiting of a flexible query API.
- Cheat sheet: OWASP "Denial of Service Cheat Sheet" (rate-limiting sections) and the OWASP API Security Top 10's API4:2023 guidance (Ch 40E).