FastAPI

FastAPI Rate Limiting (Stop Abuse with slowapi)

Thirdy Gayares
12 min read

šŸŽÆ 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 the 429 response
  • 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 429 with a proper Retry-After header

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 endpoints

The 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.

app/main.py (the naive version)
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
āš ļø
This is how accounts get brute-forced. No limit also means scrapers can drain your data, and a single client can spike your CPU, database, or third-party API bill. Rate limiting is a baseline defense every public API needs — especially on /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.

app/limiter.py
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)
app/main.py
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)
šŸ’”
The 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.

app/main.py
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": "..."}
āš ļø
The #1 slowapi gotcha: forget 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 stringMeaning
"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
app/limiter.py
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"])
app/main.py
# 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"}
šŸ’”
Use 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.

app/limiter.py
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:

app/main.py
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": [...]}
šŸ’”
Ang importante dito: choose the key that matches what you're protecting. Protecting a login from brute force? Key by IP and username. Enforcing a paid plan's quota? Key by API key or user id.

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:

app/limiter.py
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"],
)
āš ļø
In-memory is a single-process illusion. The instant you scale horizontally (multiple workers, pods, or machines) your real limit becomes 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)

requirements.txt
fastapi==0.116.1
uvicorn[standard]==0.30.6
slowapi==0.1.9
redis==5.0.8              # only if you use the Redis backend
1Run the app
run.sh
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload
2Hammer the endpoint past its limit
# 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"}
āœ…
You did it: the first 5 requests pass, the 6th gets a clean 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.

app/main.py
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())},
    )
šŸ’”
Keep the message vague on auth routes. "Too many requests" is enough — don't reveal whether the username exists or how close they are to the limit; that's useful intel for an attacker.

Common Mistakes (and Fixes)

MistakeSymptomFix
Missing request: Request paramStartup error: no "request" argumentAdd request: Request to the handler
In-memory store with multiple workersReal limit = limit Ɨ workersUse storage_uri="redis://..."
Rate limiting health checksService marked unhealthy & restarted@limiter.exempt those routes
Keying by IP behind a proxyEveryone shares the proxy's IP → one bucketTrust X-Forwarded-For (see Production Notes)
Only limiting after authBrute force still hammers the login itselfLimit the login/signup routes directly
Verbose 429 messages on authLeaks limit/username info to attackersKeep 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 or ProxyHeadersMiddleware) 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 /login and /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

āœ…
šŸš€ Recommended next reads:
  • fastapi-jwt — the login flow you most want to rate limit
  • fastapi-redis-caching — you already have Redis; use it as the limiter backend
  • fastapi-middleware — where request context (like the real client IP) is set
  • fastapi-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

About the Author

TG

Thirdy Gayares

Passionate developer creating custom solutions for everyone. I specialize in building user-friendly tools that solve real-world problems while maintaining the highest standards of security and privacy.