Frontend Labs
⋯

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)

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Add the RateLimit-* (or X-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

  1. Token bucket vs leaky bucket — what does each do to a burst of requests?
  2. Why is GCRA attractive for a high-scale, distributed rate limiter?
  3. Give four dimensions to rate-limit on beyond per-IP, and why per-IP alone is insufficient for an authenticated API.
  4. Describe the race-condition bypass against a naive rate limiter and the fix.
  5. Why does trusting a client-supplied X-Forwarded-For header break rate limiting, and what's the fix?
  6. 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 throttled and 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).

Generated from real-life-security-guide-v1.md · 8 Oct 2026 16:36