Request rate limiting¶
Editions: OSS, Cloud, Enterprise. Unless stated otherwise, everything on this page ships in OSS.
Preloop throttles its public and authentication endpoints (EE #66). Every rule is a fixed window: at most limit requests per window seconds for one identity. A refused request gets HTTP 429:
HTTP/1.1 429 Too Many Requests
Retry-After: 42
Content-Type: application/json
{"detail": "Too many requests. Try again in 42 seconds.", "code": "rate_limited", "retry_after": 42}
Rules and defaults¶
| Rule | Endpoints | Counted per | Default |
|---|---|---|---|
auth.login.ip |
POST /api/v1/auth/token, POST /api/v1/auth/token/json |
client address | 10 / 60 s |
auth.login.user |
same | account and client address, failed attempts | 5 / 60 s |
auth.refresh |
POST /api/v1/auth/refresh |
client address | 60 / 60 s |
auth.reset_password |
POST /api/v1/auth/reset-password |
client address | 10 / 600 s |
auth.register |
POST /api/v1/auth/register |
client address | 10 / 3600 s |
oauth.token |
POST /oauth/token |
client address | 30 / 60 s |
oauth.register |
POST /oauth/register |
client address | 10 / 3600 s |
oauth.authorize |
GET /oauth/authorize |
client address | 60 / 60 s |
approval.public.ip |
GET /approval/{id}/data, POST /approval/{id}/{action} |
client address | 30 / 60 s |
approval.public.request |
same | approval request and client address | 10 / 60 s |
auth.passkey_challenge |
POST /api/v1/auth/webauthn/register/options, POST /api/v1/auth/webauthn/authenticate/options |
client address¹ | 30 / 60 s (WEBAUTHN_CHALLENGE_RATE_LIMIT) |
auth.email_send |
POST /api/v1/auth/resend-verification, POST /api/v1/auth/forgot-password (one budget for both) |
client address¹, and separately the target address | 3 / 900 s (EMAIL_VERIFICATION_RESEND_LIMIT, EMAIL_VERIFICATION_RESEND_WINDOW) |
telemetry.otlp_ingest |
POST /api/v1/telemetry/otlp/v1/{metrics,logs,traces} |
API key | 600 / 60 s (PRELOOP_OTLP_INGEST_RATE_LIMIT) |
notifications.test_push |
POST /api/v1/notification-preferences/me/test-push |
user | 5 / 60 s |
auth.login.user counts wrong-password attempts for one account (a username
and an email of the same person share it) from one client address, and is
checked before the password is verified, so guesses beyond the budget never
reach the password hash. Because the address is part of the key, someone
guessing from elsewhere cannot lock the real user out; users behind the same
NAT as the guesser can be throttled for the rest of the window. There is no
lockout beyond the window. Passkeys and single sign-on are not affected.
The last four rules predate the shared limiter and moved onto it with their
defaults, setting names and keys unchanged. Their original settings (in
brackets above) still set the limit; RATE_LIMIT_<RULE> overrides them like
any rule. auth.email_send charges the client address and the target address
together: a request refused on either is not charged to the other.
Refusals keep each endpoint's own message in detail. OTLP ingest keeps the
OTLP status envelope ({"code": 429, "message": "Rate limit exceeded"}) with
Retry-After, because exporters parse that body.
¹ These two rules key on the leftmost X-Forwarded-For address, as before,
not on the trusted-proxy walk described below.
Configuration¶
| Variable | Default | Meaning |
|---|---|---|
RATE_LIMIT_ENABLED |
true |
false turns every rule off |
RATE_LIMIT_BACKEND |
postgres |
postgres shares counters across API replicas; memory keeps them per process |
RATE_LIMIT_<RULE> |
see table | <limit>/<seconds>; the rule name upper-cased with dots as underscores, for example RATE_LIMIT_AUTH_LOGIN_IP=20/60. 0/60 turns one rule off |
RATE_LIMIT_TRUSTED_PROXIES |
loopback and private ranges | comma-separated CIDRs of proxies allowed to add X-Forwarded-For hops |
WEBAUTHN_CHALLENGE_RATE_LIMIT |
30 |
requests per minute for auth.passkey_challenge (read at startup) |
EMAIL_VERIFICATION_RESEND_LIMIT, EMAIL_VERIFICATION_RESEND_WINDOW |
3, 900 |
budget and window in seconds for auth.email_send |
PRELOOP_OTLP_INGEST_RATE_LIMIT |
600 |
requests per minute per key for telemetry.otlp_ingest (read at startup) |
The Helm chart has no dedicated rate-limit values: like other API tuning
settings, these are set through extraEnv, which reaches the API pods, for
example:
Shared counters¶
The postgres backend keeps counters in the rate_limit_bucket table and
updates them with one atomic statement, so N API replicas share one budget and
a restart does not reset it. Expired rows are purged by the API itself.
If the database cannot answer a limiter query, the API logs
Rate limit store unavailable (at most once a minute per process) and applies
the same rule with in-process counters. Requests are not refused because the
limiter failed, and they are not left unthrottled either.
Client address and proxies¶
The client address is found by walking the X-Forwarded-For chain plus the
TCP peer from the right and skipping every hop inside
RATE_LIMIT_TRUSTED_PROXIES. The first address outside those ranges is the
one your own edge proxy saw; anything a client wrote into X-Forwarded-For
sits to its left and is never used. X-Real-IP is ignored.
- Helm and Docker Compose work with the default (their proxies run on private addresses).
- If your users also connect from private addresses (an intranet install),
narrow
RATE_LIMIT_TRUSTED_PROXIESto your proxies' addresses. Otherwise the leftmost address is used, which a client can choose. - If your load balancer replaces
X-Forwarded-Forinstead of appending to it, every user shares the load balancer's bucket. Configure it to append (or enableuse-forwarded-headerson ingress-nginx behind a trusted L7 balancer).
Observability¶
Each refusal logs Rate limit hit: rule=... key=... retry_after=... (the key
is a hash prefix, never the address or username) and increments the
OpenTelemetry counter preloop_rate_limit_hits_total{rule}. Store fallbacks
increment preloop_rate_limit_store_errors_total{rule}. Counters are exported
when OTLP metrics are configured.
Ingress-level limits remain a useful optional extra layer.