Module 04: Rate Limiting and Abuse Prevention
Why this matters
Modules 01-03 were about a single malicious request doing damage. This module is about the opposite: requests that are individually legitimate but catastrophic in volume. Without a limit on how often a caller can hit an endpoint, an attacker gets unlimited attempts — and unlimited attempts break things that are secure against any single request:
- Brute force / credential stuffing — guessing passwords or replaying leaked credential lists until one works. Track 03 hardened the login (argon2, constant-time, MFA); none of that matters if the attacker can try ten million passwords. Rate limiting is what makes those defenses hold.
- Scraping and enumeration — walking every
/users/{id}or/products/{id}to steal your whole catalog or user base. - Resource exhaustion / DoS — hammering an expensive endpoint (a report, a search, a signup that sends email) until the service falls over or the bill explodes.
- Abuse of costly actions — flooding password-reset emails, SMS codes, or free-tier compute.
Rate limiting is the single most important abuse control, and it's a secure-by-design decision (module 00, A04): a login endpoint without one is insecure by design, not by bug. This module gives you the two algorithms worth knowing (token bucket and sliding window), a correct Redis implementation (track 06 leaned on Redis for exactly this), and the surrounding abuse- prevention toolkit — because a limiter you can trivially bypass is no limiter.
Concepts
What rate limiting is, and the key design questions
Rate limiting caps how many operations a given subject may perform in a given
time window, rejecting the excess (typically with HTTP 429 Too Many Requests). Before picking an algorithm, answer three design questions — getting
these wrong makes even a perfect algorithm useless:
- Limit by what key? IP address, user id, API key, or a combination. Each has a failure mode: per-IP punishes users behind shared NAT/corporate proxies (many real users, one IP) and is evaded by attackers with many IPs (botnets, cloud); per-user can't protect pre-login endpoints (there's no user yet — the login itself); per-API-key is clean for machine clients. Real systems combine: per-IP and per-account on login, per-key for APIs, with different limits.
- Limit where? At the edge (API gateway, reverse proxy, WAF, Cloudflare) or in the app. The edge is cheaper and stops floods before they touch your code; the app has the context (which user, which action) for precise limits. Do both — a coarse edge limit plus fine-grained app limits (defense in depth).
- What limit, and what response? Set per-endpoint (a login gets a tight
limit; a read endpoint a loose one). On exceed, return
429with aRetry-Afterheader so well-behaved clients back off, and make sure the rejection itself is cheap (you're defending against volume — the limit check must cost far less than the work it's protecting).
Fixed window — simple, with a burst flaw
The simplest algorithm: a counter per (key, time-window), incremented per
request, reset each window. "Max 100 requests per minute" = a counter keyed by
user:minute that resets every minute.
# Fixed window with Redis INCR + EXPIRE (track 06's pattern).
def fixed_window(key: str, limit: int, window_s: int) -> bool:
n = redis.incr(f"rl:{key}")
if n == 1:
redis.expire(f"rl:{key}", window_s) # start the clock on first hit
return n <= limit
It's cheap and easy but has the boundary-burst flaw: a client can send
limit requests at 11:00:59 and another limit at 11:01:00 — 2 × limit in
two seconds — because the window reset in between. For a login limiter that
"burst" is exactly what you're trying to stop. Fine for coarse throttling,
insufficient for tight abuse limits.
Fixed window (limit 5/min): |11:00 ....#####|11:01 #####....|
└at :59──┘└─at :00┘
► 10 requests in ~2s straddling the reset boundary
Sliding window: counts any rolling 60s span ─► the 2nd burst is correctly rejected
Sliding window — smoothing the boundary
Sliding-window algorithms fix the burst by considering a rolling window rather than fixed calendar buckets:
- Sliding window log — store a timestamp per request (a Redis sorted set), drop entries older than the window, and count what remains. Exact, but stores one entry per request (memory-heavy under load).
- Sliding window counter — an approximation that weights the previous fixed window by how much of it still overlaps the rolling window. Nearly as accurate as the log, far cheaper. The common production choice.
# Sliding window log with a Redis sorted set — exact, no boundary burst.
import time
def sliding_log(key: str, limit: int, window_s: int) -> bool:
now = time.time()
k = f"rl:{key}"
pipe = redis.pipeline()
pipe.zremrangebyscore(k, 0, now - window_s) # evict entries older than the window
pipe.zadd(k, {f"{now}": now}) # record this request
pipe.zcard(k) # count what's left in the window
pipe.expire(k, window_s)
_, _, count, _ = pipe.execute()
return count <= limit
Token bucket — allowing controlled bursts
The token bucket is the most flexible and the one most gateways/libraries use.
Model: a bucket holds up to capacity tokens and refills at rate tokens per
second; each request removes one token; if the bucket is empty, the request is
rejected (or waits). Two parameters give you independent control of burst
(the capacity — how many can arrive at once) and sustained rate (the refill
— the long-run average). That's its advantage: it permits legitimate bursts (a
page that fires 10 API calls on load) while still capping the sustained rate,
where a naive per-second limit would reject the burst.
refill: +1 token/sec ─┐ capacity = 10, refill = 1/sec
▼
[ ########## ] ◄─ burst of 10 requests drains it to [ ] (all pass)
│ then it refills ~1/sec ─► ~1 req/sec sustained
▼
drain: 1 token per request; bucket empty ⇒ reject (429)
# Token bucket, conceptually (production: do this atomically in a Redis Lua script
# so the read-compute-write can't race between concurrent requests).
def token_bucket(key, capacity, refill_per_s) -> bool:
now = time.time()
tokens, last = get_bucket(key) # stored: tokens + last-refill time
tokens = min(capacity, tokens + (now - last) * refill_per_s) # refill by elapsed time
if tokens < 1:
return False # empty → reject (429)
save_bucket(key, tokens - 1, now) # spend a token
return True
The leaky bucket is a close cousin (requests queue and drain at a fixed rate, smoothing bursts out instead of allowing them); token bucket is more common for API rate limiting. Rule of thumb: token bucket when you want to allow bursts up to a cap; sliding window when you want a strict "N per period" with no burst.
Why Redis, and the atomicity trap
Rate limit state must be shared across all your app instances — if you run
three FastAPI workers and keep the counter in each process's memory, an attacker
gets 3 × limit (one bucket per worker), and the count resets on every deploy.
A shared store (Redis) is the standard answer — the same role it played as
broker/cache in track 06.
Per-process (broken): worker1[ctr] worker2[ctr] worker3[ctr] ─► attacker gets 3× limit
Shared Redis (correct): worker1 ┐
worker2 ┼─► [ Redis: atomic INCR / Lua EVAL ] ─► one global
worker3 ┘ limit, no race
The trap is atomicity. A rate-limit check is read-modify-write (read the count/tokens, decide, write back). Under concurrency, two requests can both read "1 token left," both decide "ok," and both spend it — the check races and the limit leaks. Fixes:
- Use atomic Redis operations:
INCRis atomic (the fixed-window snippet is safe); the sorted-set pipeline should run as a transaction/Lua script. - For token bucket, do the whole read-compute-write in a Lua script
(
EVAL), which Redis runs atomically — the standard production implementation. - Or use a battle-tested library. In Python,
slowapi(built onlimits) wires rate limiting into FastAPI with a Redis backend and correct atomics;fastapi-limiteris another. Prefer these over hand-rolling unless you're learning (you are — so build one by hand once, then use a library).
The response, and abuse prevention beyond rate limits
A correct rejection and the wider toolkit:
429 Too Many Requests+Retry-After(seconds or an HTTP-date) so clients back off politely. Optionally exposeX-RateLimit-Limit/Remaining/ Resetheaders so good clients self-regulate.- Account lockout / exponential backoff (track 03 m07) complements rate limiting: rate limiting protects the endpoint from volume; lockout protects a specific account from targeted guessing. Remember its DoS caveat — naive lockout lets an attacker lock a victim out on purpose; prefer backoff, lock on IP+account, don't reveal lockout state.
- CAPTCHA / proof-of-work after a threshold, to separate humans from bots without hard-blocking.
- Progressive friction — a small tarpit/delay on suspicious traffic costs attackers (who need volume) far more than the occasional real user.
- Cost-based limiting — weight expensive endpoints more (a report costs 10 "tokens," a health check 1), so you limit work, not just request count.
- Anti-enumeration — cap and monitor sequential-id access patterns (module 00's IDOR signature); rate-limit per-resource-type reads to blunt scraping.
- Never trust a client-provided identity for the key. Rate-limiting by a
header the client sets (a
X-User-Idthey choose, or a spoofableX-Forwarded-For) is trivially bypassed by rotating it. Key off something you establish (authenticated user id, the real peer IP as your proxy sets it).
Command reference
| Pattern | Purpose | Snippet |
|---|---|---|
redis.incr + expire |
fixed-window counter (atomic incr) | n=incr(k); if n==1: expire(k,w) |
| sorted-set + pipeline | sliding-window log (exact) | zremrangebyscore/zadd/zcard |
Lua EVAL script |
atomic token bucket | read-compute-write in one atomic op |
slowapi / fastapi-limiter |
production limiter for FastAPI | Redis backend, decorators |
429 + Retry-After |
correct rejection | HTTPException(429, headers={"Retry-After":"60"}) |
| per-IP and per-account key | login abuse defense | limit both; combine with lockout |
X-RateLimit-* headers |
let good clients self-regulate | Limit/Remaining/Reset |
real peer IP (not X-Forwarded-For blindly) |
don't key off spoofable input | trust only your proxy's set value |
A FastAPI dependency doing per-IP + per-account login limiting with 429:
import time
from fastapi import APIRouter, Request, HTTPException
router = APIRouter()
def rate_limit(key: str, limit: int, window: int): # fixed-window, atomic INCR
n = redis.incr(f"rl:{key}")
if n == 1:
redis.expire(f"rl:{key}", window)
if n > limit:
ttl = redis.ttl(f"rl:{key}")
raise HTTPException(429, "too many attempts",
headers={"Retry-After": str(max(ttl, 1))})
@router.post("/login")
def login(request: Request, username: str, password: str):
ip = request.client.host # trust your proxy to set this correctly
rate_limit(f"login-ip:{ip}", limit=10, window=60) # endpoint flood control (per IP)
rate_limit(f"login-user:{username}", limit=5, window=300) # targeted-account control
user = authenticate(username, password) # constant-time (track 03 m07)
if not user:
raise HTTPException(401, "invalid credentials") # one generic message
return {"ok": True}
# In production, prefer slowapi:
# from slowapi import Limiter; limiter = Limiter(key_func=get_remote_address, storage_uri="redis://…")
# @router.post("/login"); @limiter.limit("5/minute")
Hands-on exercises
Continue in sec-track. Run Redis in Docker (docker run -p 6379:6379 redis),
same as track 06. Have your track-03 login endpoint handy — it's the natural
thing to protect.
1. Implement a fixed-window limiter
Write the rate_limit helper (Redis INCR/EXPIRE) and apply it to an
endpoint at 5 requests/minute. Fire 7 rapid curls. Expected: the first 5
succeed, the 6th and 7th return 429 with a Retry-After. Confirm the counter
resets after the window.
2. Expose the fixed-window burst flaw
With a 5/minute limit, send 5 requests in the last second of one minute and 5 in the first second of the next. Expected: 10 requests succeed within ~2 seconds — double the intended rate. Write one sentence on why this defeats a tight login limiter.
3. Fix the burst with a sliding-window log
Reimplement the limiter using a Redis sorted set (the sliding_log snippet).
Repeat the boundary test from exercise 2. Expected: the burst is now correctly
rejected — no more than 5 in any rolling 60-second window regardless of clock
alignment.
4. Build a token bucket and watch it allow controlled bursts
Implement a token bucket (capacity 10, refill 1/sec). Send 10 requests instantly (all succeed — the burst), then observe that further requests succeed roughly once per second as tokens refill. Expected: bursts up to capacity are allowed while the sustained rate is capped — the behavior a strict per-second limit can't give you.
5. Prove the atomicity trap, then fix it
Point your token-bucket read-compute-write at a bucket with 1 token and fire ~50
concurrent requests (threads or asyncio.gather of httpx calls). With a naive
non-atomic implementation, count how many got through. Expected: more than one
slips through (the race). Move the logic into a Redis Lua EVAL (or use INCR)
and confirm exactly one succeeds.
6. Prove the per-process trap
Run your app with 2+ workers (uvicorn --workers 2) using an in-memory dict as
the limiter store. Hammer the endpoint. Expected: you get roughly limit × workers through, because each worker has its own counter. Switch to Redis and
confirm the limit is now global.
7. Rate-limit login by IP and account
Protect your track-03 login with two keys: per-IP (endpoint flood) and per-account (targeted brute force). Demonstrate: hammering one account from one IP trips the account limit; spreading attempts across many accounts from one IP still trips the IP limit. Expected: neither "many guesses at one account" nor "one guess at many accounts" gets unlimited attempts.
8. Adopt a library and add cost-based limits
Swap your hand-rolled limiter for slowapi (Redis storage) on a couple of
endpoints. Then make an expensive endpoint (a report) consume more budget than a
cheap one. Expected: you can articulate why limiting work (cost) beats limiting
raw request count, and you trust a maintained implementation for the atomics.
9. Diagnose and fix: the reset-password flood
Audit this endpoint for abuse issues and fix them.
@app.post("/reset-password")
def reset(email: str, request: Request):
key = request.headers.get("X-Client-Id", "anon") # client sends this
n = LOCAL_COUNTS.get(key, 0) + 1 # in-process dict
LOCAL_COUNTS[key] = n
if n > 100:
raise HTTPException(429)
send_reset_email(email) # costly: sends an email each call
return {"sent": True}
Solution
Flaws: (1) Client-controlled key — limiting by X-Client-Id (a header the
caller sets) is trivially bypassed by rotating it; key off something you
establish (the real peer IP set by your proxy, and/or the target email). (2)
In-process counter — LOCAL_COUNTS isn't shared across workers and resets on
deploy, so the real limit is 100 × workers and evaporates on restart; use
Redis. (3) Limit far too loose for a costly action — 100 reset emails per
window is an email-flood/DoS-on-the-victim vector; tighten drastically (e.g.
3–5 per email per hour) since each call sends mail. (4) Per-email
enumeration/abuse — also key by target email and, per track 03 m07, return the
same "if that account exists, we sent a link" response whether or not the email
exists (don't reveal existence). (5) No Retry-After. Corrected shape:
Redis-backed sliding-window limit keyed by real IP and target email, tight
limits for the costly send, 429+Retry-After, and an enumeration-safe
response.
Independent challenge
No code given. Add a complete abuse-prevention layer to sec-track's most
sensitive endpoints: the login and the password-reset. Requirements: a
Redis-backed limiter (so it's correct across multiple workers — prove the
per-process trap doesn't apply to you), atomic (no race under concurrency —
justify how you guarantee it), keyed by real peer IP and target
account/email (never a client-settable header), returning 429 +
Retry-After, with an algorithm you can justify (token bucket vs sliding
window — say which and why for each endpoint). Layer it with account
backoff (reach back to track 03 module 07 — and avoid its DoS/enumeration
pitfalls) and make the reset endpoint enumeration-safe. Then write a note
mapping each control to the specific abuse it stops (brute force, credential
stuffing, reset-email flood, scraping) and explaining why rate limiting is a
secure-by-design (module 00 / A04) requirement rather than a feature you add
after an incident.
Hint
The two mistakes that quietly void a rate limiter are non-shared state and
a spoofable key — get either wrong and the limit is decorative. Shared state
means Redis (or another central store), not per-process memory, so N workers
don't multiply the limit N-fold; atomicity means the read-compute-write happens
as one indivisible operation (an atomic INCR, or a Lua EVAL for token
bucket) so concurrent requests can't both spend the last token. For the key,
anything the client can set (X-Client-Id, a raw X-Forwarded-For) can be
rotated to reset the counter — key off the peer IP your proxy sets plus the
authenticated/target identity. Choose the algorithm per endpoint by whether
bursts are legitimate: an API that fires several calls on page load wants token
bucket; a login where any burst is suspicious wants a strict sliding window.
Common mistakes & troubleshooting
- In-process/in-memory rate limit state. Multiplies the limit by your worker count and resets on deploy. Use a shared store (Redis).
- Non-atomic read-compute-write. Concurrent requests race and the limit
leaks. Use atomic ops (
INCR) or a LuaEVALscript; or a vetted library. - Keying off client-settable input.
X-Client-Id, unvalidatedX-Forwarded-For— trivially rotated to bypass. Key off established identity and the real peer IP your proxy sets. - Fixed window for tight limits. The boundary-burst allows ~2× the limit across a window edge. Use sliding window (or token bucket) where the burst matters.
- Per-IP only. Punishes shared-NAT users and is evaded by many-IP attackers. Combine per-IP with per-account/per-key.
- No
Retry-After/ expensive rejection. Good clients can't back off, and a costly limit check undermines the point. Return429+Retry-Aftercheaply. - Limiting count, not cost. A few requests to a very expensive endpoint can still exhaust you. Weight expensive actions higher (cost-based).
- Treating rate limiting as an afterthought. It's secure-by-design (A04) — an unlimited login/reset is insecure by design. Build it in, don't bolt it on post-incident.
Checkpoint quiz
Write down your answer to each question before expanding it — checking without attempting first is the single easiest way to fool yourself into thinking you've learned this.
- Name the three design questions to answer before choosing a rate-limit algorithm, and a failure mode for keying by IP alone.
- Describe the fixed-window burst flaw and which algorithm fixes it.
- What does a token bucket give you that a strict per-second limit does not, and name its two parameters.
- Why must rate-limit state live in a shared store, and what is the atomicity trap and its fix?
- Why is rate-limiting by a client-supplied header (or raw
X-Forwarded-For) broken, and what should you key off instead? - How do rate limiting and account lockout differ in what they protect, and what's the danger of naive lockout?
- Why is rate limiting a secure-by-design concern rather than a feature you add after an incident?
Answers
- What key (IP/user/API key/combination), where (edge vs app), and what limit + what response. Keying by IP alone punishes users behind shared NAT/proxies (many users, one IP) and is evaded by attackers with many IPs (botnets/cloud).
- A fixed-window counter resets on calendar boundaries, so a client can send a full window's worth just before the reset and another just after — ~2× the limit in seconds. A sliding window (log or counter) considers a rolling window and fixes it.
- It allows controlled bursts up to a capacity while still capping the sustained rate, where a strict per-second limit would reject a legitimate burst. Parameters: capacity (max burst) and refill rate (sustained average).
- Because otherwise each worker/process keeps its own counter, so N workers give
~N× the limit and it resets on deploy — a shared store (Redis) makes it
global. The atomicity trap: the read-modify-write can race so two concurrent
requests both spend the last token; fix with atomic ops (
INCR) or a LuaEVALscript. - Anything the client sets (
X-Client-Id, unvalidatedX-Forwarded-For) can be rotated per request to reset the counter, so it enforces nothing. Key off something you establish: the authenticated user/target account and the real peer IP as set by your trusted proxy. - Rate limiting caps request volume per key/window, protecting the endpoint from brute force/stuffing/floods; account lockout protects a specific targeted account after N failures. Naive lockout is dangerous because an attacker can deliberately lock out a victim (DoS) and lockout responses can leak which accounts exist — prefer backoff, lock on IP+account, hide state.
- Because an endpoint with no limit (login, reset, expensive report) is insecure by design, not by a code bug — the vulnerability is the missing control, so it must be designed in up front (A04). Adding it only after a brute-force or flood incident means you shipped the hole knowingly.
Further reading & sources
- OWASP Denial of Service Cheat Sheet - rate limiting and resource-exhaustion defenses.
- OWASP Authentication Cheat Sheet - rate limiting & lockout - protecting login against brute force and credential stuffing.
- Redis - INCR and rate limiting patterns - the atomic counter pattern (with the fixed-window example) straight from the Redis docs.
- Redis - EVAL / Lua scripting - running read-compute-write atomically for a correct token bucket.
- SlowApi documentation - the FastAPI/Starlette rate limiter used in this module.
- CWE-307: Improper Restriction of Excessive Authentication Attempts - the formal weakness this module defends against.
- Designing a Rate Limiter - the same algorithms as a distributed system-design interview problem: a shared Redis store, read-modify-write races, and the trade-offs between algorithms.
- Retries, Prioritization, and Rate Limiting in Queues - the outbound side: throttling your own workers so they respect a downstream API's limits.
Free PDF notes
Download these notes as a printable PDF, with every diagram and interview questions: SD-C-012: Rate Limiting Algorithms. Each section in one file: Phase 2, APIs (PDF).
Next
05-secrets-management — you've defended the surface of the app; now defend the keys to the kingdom. Never hardcoding secrets, environment- and vault-based secret storage, rotation, and keeping secrets out of logs and git history. That module also carries this track's second cumulative review (modules 00-05), taken closed-book.