Rate limiting & lockout¶
Two related protections: a general-purpose per-endpoint rate limiter you attach to any route, and an escalating login lockout that's built into the auth flows. Both run over the same rate-limiter backend (in-memory by default, Redis in production).
Throttling a route¶
auth.rate_limit(action, limit, key=...) builds a dependency you add to any endpoint:
from fastapi import Depends
from crudauth.ratelimit import RateLimit, KeyBy
@app.post("/contact", dependencies=[Depends(auth.rate_limit("contact", RateLimit(5, 60)))])
async def contact(...):
...
RateLimit(times, seconds) is the budget (5 per 60s above). It writes X-RateLimit-*
headers and raises a RateLimitException (429 with Retry-After) when the caller is over
budget. key decides who shares a budget:
KeyBy.IP(the default): the client IP.KeyBy.USER: the authenticated user. Unauthenticated requests get401.KeyBy.USER_OR_IP: the user when the request is authenticated, otherwise the client IP.- A function of the request,
key(request), or of the request and the principal (Nonewhen anonymous),key(request, principal). The principal is passed only when the function takes two required positional arguments.
The limit can depend on the request as well. Pass a function instead of a RateLimit: it
receives the request and the principal, may be sync or async, and returns a RateLimit, or
None for no limit on that request:
async def plan_limit(request: Request, principal: Principal | None) -> RateLimit | None:
if principal is None:
return RateLimit(20, 60)
if principal.is_superuser:
return None
return RateLimit(200, 60)
@app.get("/search", dependencies=[Depends(auth.rate_limit("search", plan_limit, key=KeyBy.USER_OR_IP))])
async def search(...):
...
The principal comes from the same cached authentication current_user() uses, so a route
with both authenticates once, and current_user() still enforces CSRF. transport= narrows
which credentials identify the caller, as it does on current_user(); give both the same
value so they keep sharing that authentication. Like current_user(optional=True), a limit
that reads the principal treats a missing or expired credential as anonymous but rejects a
tampered one with 401.
A limit that doesn't need the principal (a fixed RateLimit keyed by IP or by
key(request)) doesn't touch the database. For your own throttling logic,
auth.rate_limiter is the configured backend.
Headers on error responses¶
X-RateLimit-Limit and X-RateLimit-Remaining go on the route's response, and the 429 carries
them with Retry-After. A request can also be counted and then refused by something after the
limiter: current_user() answering 401, the route raising a 404, a 422 for a bad body.
FastAPI builds those responses from the exception, so they leave without the headers, and a
client reading the headers to pace itself can't see that the refused requests spent budget.
Add RateLimitHeadersMiddleware to put the headers on those responses too:
from crudauth.ratelimit import RateLimitHeadersMiddleware
app.add_middleware(RateLimitHeadersMiddleware)
It only fills in headers a response doesn't already have, and a request no limiter counted gets
none. When several limiters count the same request, the headers describe the one with the least
remaining, since that's the budget the client runs into first. Responses built outside the
middleware stack, such as an unhandled exception's 500, go out without them.
The built-in account actions ship with defaults. Override them per action with rate_limits={...}
on CRUDAuth:
An unknown key raises at construction, so these are the ones to pass:
| Action | Default | Guards |
|---|---|---|
register |
5 / hour | POST /register |
email_verify_request |
5 / hour | POST /email/verify-request |
password_reset_request |
5 / hour | POST /password/reset-request |
email_change_request |
3 / hour | POST /email/change-request |
existing_account_notice |
5 / hour | the "you already have an account" notice |
change_password |
5 / hour | POST /change-password and POST /set-password |
logout_all |
10 / hour | POST /logout-all |
csrf_refresh |
30 / hour | POST /csrf/refresh |
oauth_authorize |
30 / hour | GET /oauth/{provider}/authorize |
mfa_manage |
10 / hour | the MFA setup, confirm, disable and recovery-code routes |
auth.rate_limits reads the merged result back, defaults included.
register counts only signups that pass validation, so a rejected password doesn't use up the
budget.
A key that isn't a built-in action raises at construction; a custom action passes its limit to
auth.rate_limit(action, RateLimit(...)). RateLimit rejects a negative times or a
seconds that isn't positive.
KeyBy.IP keys an IPv6 client by its /64, the block one subscriber is normally given, so
rotating through addresses in it doesn't buy fresh budgets. The register limit and the login
lockout key IPs the same way. See client_ip_key.
Login lockout¶
The login path (the session /login and the bearer /token, which share it) has its own
escalating lockout, separate from rate_limit(). Repeated failures from an IP + username
trip a block whose duration doubles each round, up to a cap. Configure it on CRUDAuth, which
works whichever transports you use:
from crudauth.ratelimit import LockoutConfig
auth = CRUDAuth(
...,
lockout=LockoutConfig(
max_attempts=5, # failures allowed in the window
attempt_window_seconds=60,
lockout_base_seconds=60, # first lockout; doubles each round
lockout_max_seconds=3600, # cap
round_retention_seconds=3600, # how long the round count survives
on_login_success="clear_all",
),
)
SessionTransport's login_max_attempts, login_attempt_window_seconds,
login_lockout_base_seconds, login_lockout_max_seconds and on_login_success set the same
values; setting them and lockout= together raises.
- Escalation: each repeat offense waits longer (60s, 120s, 240s, ... up to the cap), and the round count persists so a slow, paced attack keeps climbing rather than resetting.
on_login_success: a good login always clears that username's counters."clear_all"(default) also takes back the per-IP failures that username added from that IP, which is friendly to users behind shared egress who mistyped before getting in. Failures against other usernames stay counted, so logging into your own account doesn't reset a spray, and an IP lock stays until it expires."clear_user_only"keeps all per-IP pressure, which is tighter but only safe when your per-IP key identifies one client.- Usernames are case-folded in the lockout key, so
bob,BobandBOBshare one budget even when your database compares them case-insensitively. - Keying behind a proxy: per-IP counters use the client IP, so set
trusted_proxy_hopsto the number of proxies in front of you. Otherwise every request looks like the proxy's IP and shares one bucket. RepeatedX-Forwarded-Forheader lines are read as one chain. Seeget_client_ip.
Lockout fails closed: if the limiter backend errors, a login is blocked rather than allowed, so an attacker can't disable it by knocking the backend over.