šÆ What You Will Learn
An endpoint with no rate limit is an open door: brute-force logins, scrapers, and a single bad actor who can hammer your API until it (or your bill) falls over. We'll close that door with slowapi.
- Why an unlimited endpoint is a real security & cost risk
- Wiring
slowapi: limiter, handler, and the429response - Per-route limits (
5/minute) and global defaults - Keying limits by IP and by API key / user
- Why in-memory breaks with multiple workers ā and the Redis fix
- Returning
429with a properRetry-Afterheader
Prerequisites: a basic FastAPI app. This pairs naturally with fastapi-jwt (the login you most want to protect) and reuses ideas from fastapi-middleware.
fastapi-rate-limiting/
āāā requirements.txt
āāā app/
āāā __init__.py
āāā limiter.py # the Limiter instance + key functions
āāā main.py # app wiring + limited endpointsThe Problem: An Open Endpoint Is an Invitation (See It First)
Here's a login route with no protection. Functionally correct ā and a gift to anyone with a password list.
from fastapi import FastAPI
app = FastAPI()
@app.post("/login")
def login(username: str, password: str):
# ...check credentials...
return {"token": "..."}Nothing stops an attacker from trying thousands of passwords per minute:
# an attacker's script ā unlimited attempts
$ for i in $(seq 1 5000); do
curl -s -X POST "http://127.0.0.1:8000/login?username=admin&password=guess$i"
done
# ...5000 login attempts in seconds, every one accepted by the server/login, /signup, and password-reset routes.Install & Wire slowapi
slowapi is the go-to rate limiter for Starlette/FastAPI (a spiritual port of Flask-Limiter). Three things to wire up: a Limiter, an exception handler that turns limit breaches into 429 responses, and (optionally) the middleware that applies global default limits.
from slowapi import Limiter
from slowapi.util import get_remote_address
# key_func decides "who" a request counts against ā here, the client IP
limiter = Limiter(key_func=get_remote_address)from fastapi import FastAPI
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
from app.limiter import limiter
app = FastAPI(title="FastAPI Rate Limiting", version="0.1.0")
# 1) register the limiter on the app
app.state.limiter = limiter
# 2) turn RateLimitExceeded into a clean 429 (with Retry-After header)
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# 3) optional: apply global default limits to every route
app.add_middleware(SlowAPIMiddleware)key_func is the heart of it: it returns the identity a request is counted against. get_remote_address uses the client IP, so "5 per minute" means 5 per minute per IP. We'll swap in smarter keys shortly.Your First Limit
Decorate the route with @limiter.limit(...). There's one non-negotiable rule that trips up everyone: the endpoint must take a request: Request parameter. slowapi reads the client identity off it ā leave it out and you get an error at startup.
from fastapi import Request
from app.limiter import limiter
@app.post("/login")
@limiter.limit("5/minute") # max 5 attempts per minute per IP
def login(request: Request, username: str, password: str):
# ^^^^^^^^^^^^^^^ REQUIRED by slowapi
# ...check credentials...
return {"token": "..."}request: Request and slowapi raises Exception: No "request" or "websocket" argument on function. It's not optional ā the limiter needs the request to find the client. Add it even if your handler doesn't otherwise use it.Limit Strings & Global Defaults
Limits are plain strings like "5/minute". You can stack several, and set app-wide defaults so every route is covered even if you forget to decorate it.
| Limit string | Meaning |
|---|---|
"5/minute" | 5 requests per minute |
"100/hour" | 100 requests per hour |
"10/second" | 10 requests per second |
"5/minute;100/day" | Both at once ā whichever trips first |
from slowapi import Limiter
from slowapi.util import get_remote_address
# every route gets 100/hour unless it declares its own stricter limit
limiter = Limiter(key_func=get_remote_address, default_limits=["100/hour"])# strict limit on a sensitive route
@app.post("/login")
@limiter.limit("5/minute")
def login(request: Request, username: str, password: str):
return {"token": "..."}
# a route that opts OUT of limiting entirely (e.g. health checks)
@app.get("/health")
@limiter.exempt
def health():
return {"status": "ok"}default_limits as a safety net, then tighten the routes that need it. And always @limiter.exempt your health/readiness checks ā a throttled health check will get your service marked unhealthy and restarted.Keying: by IP vs by API Key / User
IP keying is a fine default, but real APIs often limit per authenticated user or per API key ā so one user on a shared office IP doesn't throttle everyone else. The key function is just a callable that returns a string.
from fastapi import Request
from slowapi import Limiter
from slowapi.util import get_remote_address
def api_key_or_ip(request: Request) -> str:
# prefer the API key; fall back to IP for anonymous callers
api_key = request.headers.get("X-API-Key")
if api_key:
return api_key
return get_remote_address(request)
limiter = Limiter(key_func=api_key_or_ip)You can also override the key on a single route ā handy for per-user limits behind auth:
def user_key(request: Request) -> str:
# request.state.user_id set by your auth dependency/middleware
return getattr(request.state, "user_id", None) or get_remote_address(request)
@app.get("/reports")
@limiter.limit("30/minute", key_func=user_key) # per-user limit
def reports(request: Request):
return {"data": [...]}Storage: In-Memory vs Redis (Read This Before Prod)
By default slowapi counts requests in local memory. That's fine for one process ā and quietly broken the moment you run multiple workers or containers, because each one keeps its own separate count.
Limit = "5/minute", but you run 4 uvicorn workers, in-memory: Worker 1: counts 5 ā Worker 2: counts 5 ā a client can actually make Worker 3: counts 5 āāāŗ ~20/minute before being limited Worker 4: counts 5 ā (each worker limits independently)
The fix is a shared store so every worker counts against the same tally. Point slowapi at Redis:
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(
key_func=get_remote_address,
storage_uri="redis://localhost:6379", # shared across all workers
default_limits=["100/hour"],
)limit Ć process_count. Use Redis (or another shared backend) in any deployment that isn't a single process. You already have Redis if you followed fastapi-redis-caching.Try It (curl the Limit)
fastapi==0.116.1
uvicorn[standard]==0.30.6
slowapi==0.1.9
redis==5.0.8 # only if you use the Redis backendpython -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload# limit is 5/minute ā the 6th request is blocked
$ for i in $(seq 1 6); do
curl -s -o /dev/null -w "%{http_code} " -X POST \
"http://127.0.0.1:8000/login?username=admin&password=x"
done
200 200 200 200 200 429
# inspect the 6th response: 429 + Retry-After tells the client when to retry
$ curl -s -D - -o /dev/null -X POST \
"http://127.0.0.1:8000/login?username=admin&password=x"
HTTP/1.1 429 Too Many Requests
retry-after: 34
content-type: application/json
{"error":"Rate limit exceeded: 5 per 1 minute"}429 Too Many Requests with a Retry-After header. Brute-forcing that login just became impractical ā and well-behaved clients know exactly when to come back.A Cleaner 429 Response (Optional)
The default handler works, but you may want the error shaped like the rest of your API. Register your own handler for RateLimitExceeded ā just keep the Retry-After header so clients back off correctly.
from fastapi import Request, status
from fastapi.responses import JSONResponse
from slowapi.errors import RateLimitExceeded
@app.exception_handler(RateLimitExceeded)
async def rate_limit_handler(request: Request, exc: RateLimitExceeded):
return JSONResponse(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
content={"detail": "Too many requests. Please slow down."},
headers={"Retry-After": str(exc.limit.limit.get_expiry())},
)Common Mistakes (and Fixes)
| Mistake | Symptom | Fix |
|---|---|---|
Missing request: Request param | Startup error: no "request" argument | Add request: Request to the handler |
| In-memory store with multiple workers | Real limit = limit Ć workers | Use storage_uri="redis://..." |
| Rate limiting health checks | Service marked unhealthy & restarted | @limiter.exempt those routes |
| Keying by IP behind a proxy | Everyone shares the proxy's IP ā one bucket | Trust X-Forwarded-For (see Production Notes) |
| Only limiting after auth | Brute force still hammers the login itself | Limit the login/signup routes directly |
| Verbose 429 messages on auth | Leaks limit/username info to attackers | Keep the error generic |
Production Notes
- Behind a proxy? Fix the client IP first. Behind Nginx/Cloudflare/a load balancer, every request looks like it comes from the proxy. Read the real IP from
X-Forwarded-For(via a trusted proxy config orProxyHeadersMiddleware) so you don't lump all clients into one bucket. - Use Redis for anything multi-process. One shared store = one honest count. In-memory is for a single dev process only.
- Tier your limits. Strict on
/loginand/signup(e.g.5/minute), looser on read endpoints, and per-plan quotas keyed by API key for paying customers. - Rate limiting is one layer. Pair it with strong password hashing (
fastapi-jwt), account lockouts, and a WAF/Cloudflare in front for volumetric attacks. No single control is enough alone.
What's Next
fastapi-jwtā the login flow you most want to rate limitfastapi-redis-cachingā you already have Redis; use it as the limiter backendfastapi-middlewareā where request context (like the real client IP) is setfastapi-corsā the other must-have gate on a public API
Recap: never ship a public API ā especially /login ā without a rate limit. Wire slowapi, remember the request: Request param, key limits by what you're protecting, back it with Redis once you scale past one process, and return a clean 429 with Retry-After. It's a small change that turns off a whole class of abuse.
fastapi-rate-limiting/ # ā
finished
āāā requirements.txt # + slowapi (+ redis)
āāā app/
āāā __init__.py
āāā limiter.py # Limiter(key_func=..., storage_uri=redis, defaults)
āāā main.py # 429 handler, per-route limits, exempt health