Skip to content

Rate limiting

Severity: mediumApplies to: FastAPI 0.100+Applies to: slowapi 0.1.xFacts last verified 2026-07-29 against FastAPI 0.141.0 · slowapi 0.1.10 · uvicorn 0.52.0

There is no rate limiting in FastAPI or Starlette. The shipped middleware is authentication, cors, errors, exceptions, gzip, httpsredirect, sessions, trustedhost and wsgi — nothing that counts requests.

So this is a control you add, and the interesting part is not choosing a library. It is that whatever you choose has to answer “who is this?”, and on FastAPI that answer comes from a setting on a different page.

slowapi is the common choice. Its default key function, in full:

def get_remote_address(request: Request) -> str:
"""
Returns the ip address for the current request (or 127.0.0.1 if none found)
"""
if not request.client or not request.client.host:
return "127.0.0.1"
return request.client.host

request.client.host is the peer address unless uvicorn has been told to honour forwarded headers. Behind a proxy without that configuration it is the proxy’s address, identically, for every visitor — so a limiter of “5 per minute” becomes five requests per minute across your entire user base, and the first legitimate burst locks everyone out.

Configure it the other way, with --forwarded-allow-ips='*', and the key becomes a value the caller supplies. Rotating X-Forwarded-For then defeats the limiter completely.

Both failures are silent. One throttles everybody, the other throttles nobody, and neither logs anything unusual. Read proxy headers before this page — the limiter inherits whatever that decides.

Note the fallback in that function too: when there is no client at all, every such request keys to the literal 127.0.0.1 and shares one bucket.

the fix
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address, headers_enabled=True)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
@app.post("/login")
@limiter.limit("5/minute")
async def login(request: Request, form: LoginForm):
...

Two details that are easy to get wrong. The decorated function must take a parameter named request — slowapi finds the request by name, and omitting it raises at call time rather than at import. And headers_enabled=True adds the RateLimit response headers, which is what makes the next section possible.

Rate limit the credential, not only the address

Section titled “Rate limit the credential, not only the address”

An IP limit stops one host trying many passwords. It does not stop a distributed attempt at one account, because each source makes few requests.

For login specifically, key on the submitted username as well, so attempts against a single account are counted wherever they come from:

@limiter.limit("5/minute") # per IP
@limiter.limit("10/hour", key_func=lambda r: r.path_params.get("username", ""))

Two limits on one endpoint is normal, and they close different attacks. Be careful that the account-keyed limit cannot be used to lock a victim out on purpose — prefer throttling the response over rejecting it outright, or pair it with a signal the attacker cannot control.

verify it workedRun this in: http response
Terminal window
for i in $(seq 1 12); do
printf '%s ' "$(curl -s -o /dev/null -w '%{http_code}' \
-X POST https://api.example.com/login \
-H 'Content-Type: application/json' \
-d '{"username":"probe","password":"wrong"}')"
done; echo

You want the codes to change to 429 partway through the run. If all twelve return the same status, the limiter is not engaged on that route.

Then confirm the limiter is keying on distinct clients rather than lumping them together — the check that catches the proxy misconfiguration:

Terminal window
curl -si -X POST https://api.example.com/login -H 'X-Forwarded-For: 203.0.113.7' \
-d '{}' | grep -i '^ratelimit'

Vary that header across a few values. If the remaining quota keeps falling regardless, every caller shares one bucket. If it resets to full for each new value, the header is being trusted and the limiter is bypassable.

In-memory storage does not survive more than one worker

Section titled “In-memory storage does not survive more than one worker”

slowapi’s default storage is in-process. uvicorn --workers 4 gives you four independent counters, so the effective limit is roughly four times what you configured, and a rolling deploy resets all of them.

For anything that matters, point it at shared storage:

limiter = Limiter(key_func=get_remote_address, storage_uri="redis://cache:6379")

This is worth knowing even at one worker, because it becomes wrong the moment somebody scales the deployment and nothing about the change looks related to rate limiting.

before you ship this

Rate limits break automation you own before they inconvenience anyone else: the monitoring probe hitting /login as a synthetic check, the integration test suite in CI, a mobile client retrying aggressively on a flaky connection, and any batch job running from a single address.

The 429s surface as intermittent failures in systems that are not the API, so the diagnosis usually starts in the wrong place.

Deploy in observe mode first if the library allows it, or start with a limit far above observed traffic and tighten once you have seen a week of real numbers:

Terminal window
# requests per source per minute, from your access log
awk '{print $1}' access.log | sort | uniq -c | sort -rn | head -20

Exempt your health check path explicitly rather than relying on it staying under the limit.

  • Proxy headers — the setting this control’s key function depends on
  • Request size limits — the other unbounded-input control with nothing shipped by default