Rate limiting#
Local authentication routes are unauthenticated and deliberately expensive. Password verification runs Argon2, which is correct for password storage and also means one cheap request costs the server real CPU. Left unbounded, those routes are both a password-guessing surface and an amplification lever, so every abuse-prone operation consumes a budget before it does any credential work.
Limiting is on by default. You do not have to configure anything to get it.
Two tiers#
Litestar already ships RateLimitConfig,
a per-route middleware keyed on the client address. Use it. It is the right tool
for coarse per-address limits across your whole application, and this library
does not reimplement it.
What middleware cannot do is key a bucket on the submitted identifier, because it runs before the request body is parsed. That distinction matters:
Address-keyed limits stop one machine hammering your login route. They do not stop a botnet spreading ten thousand guesses for one account across ten thousand addresses, where every address looks idle.
Identifier-keyed limits stop that. They do not stop one machine spraying one common password across ten thousand accounts, where every account sees a single attempt.
You need both. This library supplies the second tier, applied inside the services, before hashing.
What is limited#
Operation |
Default budget |
Buckets |
|---|---|---|
|
10 / 5 min |
client + identifier |
|
10 / 5 min |
client + account |
|
5 / hour |
client + identifier |
|
5 / hour |
client + identifier |
|
5 / hour |
client + identifier |
|
10 / 5 min |
client |
|
10 / hour |
client |
|
10 / 5 min |
client + account |
|
60 / 5 min |
client |
|
5 / hour |
client + account |
|
5 / hour |
client + account |
Session and token login share one local.login budget on purpose. They
present the same credential to the same account store, so separate budgets would
let an attacker double their allowance by alternating between /auth/login
and /auth/token.
MFA-login completion uses its own local.login.mfa budget. Its challenge is
already bound to one account, so the limiter uses client and account buckets;
the password-login allowance cannot be reused to make extra factor guesses.
Password reset, verification confirmation, and refresh rotation are keyed on the client only. The value they present is a token, not an identifier, and digesting it into a bucket key would let the limiter backend become a record of which tokens were attempted.
local.password.verify is the step-up password factor: re-verifying the
password of an already-authenticated principal. It shares the TOTP verification
cadence because both are second-factor checks, and it is keyed on the account so
a stolen session cannot brute-force the password from many addresses. Factor
and credential removal consume a budget before the step-up grant is even
examined, so guessing at removals is bounded the same way as guessing at
credentials.
The MFA and passkey ceremonies — enrollment, verification, recovery-code
consumption and replacement, and the registration and authentication options —
carry budgets of their own under local.mfa.* and local.passkey.*. Every
operation the generated routes limit appears in
DEFAULT_RATE_LIMIT_POLICIES.
Responses#
A denied attempt returns 429 with a Retry-After header when the limiter
reports one. A limiter that raises is treated as unavailable and fails
closed with 503 — an outage must not silently remove the limit.
Both statuses appear in the generated OpenAPI document.
Choosing a limiter for your deployment#
The bundled limiter holds a store name, not a store, and resolves it from the application store registry at startup. An unregistered name yields Litestar’s in-memory default, which is correct for a single process and multiplies by your worker count if you do not change it.
Point the name at a shared backend when every process must see the same bucket values:
from litestar import Litestar
from litestar.stores.redis import RedisStore
from litestar_security.accounts import RATE_LIMIT_STORE_NAME
app = Litestar(
route_handlers=[],
stores={RATE_LIMIT_STORE_NAME: RedisStore.with_client("redis://localhost:6379")},
)
StoreRateLimiter is exact across its instances and event loops within one
process: it serializes its complete read-modify-write operation with a
process-wide lock. A shared store makes bucket values visible to other
processes, but it cannot make that cycle atomic between processes or machines.
For a multi-process deployment, provide an application limiter backed by an
atomic backend primitive, then validate it with
assert_rate_limiter_conformance().
Trusting the right client key#
The default client key is the peer address, and it deliberately does not
honour X-Forwarded-For. Those headers are attacker-controlled unless a proxy
you operate rewrote them, so trusting them by default would let anyone mint
unlimited buckets by varying one header.
Behind a proxy, use forwarded_client_key()
and configure the exact CIDR ranges and number of forwarding hops that you
operate:
from litestar_security.accounts import LocalAuth, forwarded_client_key
local_auth = LocalAuth.session(
accounts=accounts,
secrets=secrets,
binding=binding,
client_key=forwarded_client_key(
trusted_proxies={"198.51.100.0/28", "2001:db8:1234:5::/64"},
max_hops=2,
),
)
Only do this when a proxy you control overwrites the header. The extractor
accepts forwarding data only from a directly connected address in those CIDRs,
then walks no more than max_hops entries from right to left and returns the
first address outside the trusted proxy ranges. An untrusted direct peer, a
missing or malformed header, or a chain whose inspected hops are all trusted
falls back to the direct peer.
Do not configure a broad network or too many hops merely to make a deployment
work. A shared-NAT proxy or an over-broad forwarding trust boundary can collapse
many unrelated clients into one client bucket, causing collateral 429
responses. Conversely, when client_key is absent or returns None there
is no client bucket at all; client-only operations lose their client limit and
combined operations retain only their identifier/account bucket. Do not replace
an unavailable client key with a shared sentinel value.
Supplying your own limiter#
RateLimiter is a port. Implement
acquire and pass it as rate_limiter:
from litestar_security.accounts import RateLimitAttempt, RateLimitDecision
class MyLimiter:
async def acquire(self, request: RateLimitAttempt) -> RateLimitDecision:
allowed, retry_after = await my_backend.consume(
request.operation, request.client_key, request.subject_digest
)
return RateLimitDecision(allowed=allowed, retry_after=None if allowed else retry_after)
request.subject_digest is a peppered HMAC of the normalized identifier, never
the identifier itself, so a limiter backend never stores email addresses. Raise
from acquire to signal an outage; the caller fails closed.
To limit only at the edge, pass
UnlimitedRateLimiter.
Tuning the budgets#
from datetime import timedelta
from litestar_security.accounts import (
DEFAULT_RATE_LIMIT_POLICIES,
RateLimitPolicy,
StoreRateLimiter,
)
limiter = StoreRateLimiter(
policies={
**DEFAULT_RATE_LIMIT_POLICIES,
"local.login": RateLimitPolicy(limit=5, window=timedelta(minutes=15)),
}
)
An operation absent from the mapping is not limited by that limiter. That is
why the shipped default map is provably exhaustive: an import-time assertion
requires DEFAULT_RATE_LIMIT_POLICIES to map
exactly RATE_LIMITED_OPERATIONS, the canonical set of operations the
library’s own routes hand to a limiter. A new library operation without a
default budget fails immediately rather than shipping unlimited. When you
replace the mapping wholesale, keep every operation you did not mean to
unlimit — starting from DEFAULT_RATE_LIMIT_POLICIES as above preserves the
guarantee.
Audit events#
A denial emits a SecurityEvent with outcome
rate_limited to the configured events sink. The event carries no account
identifier: the denial was keyed on a digest, and resolving it back to an
account would defeat the point of digesting it.