Module kit / Identity and auth
Rate limiting
identity-auth/rate-limiting
| Kind | backend |
|---|---|
| Domain | identity-auth |
| Maturity | prod |
| Reusable | yes |
| Stack | fastapi-0.118, starlette-basehttpmiddleware, redis-6, uvicorn-0.37 |
| Depends on | _shared/fastapi-project-layout, _shared/auth-dependency-pattern, _shared/error-envelope, _shared/test-layout, _shared/cloudflare-worker-and-estate-deploy |
| Frontends | identity-auth/ui/auth-screens |
| Reference implementation |
|
| Measured on | alchemy_labs d1a-token-source fd8558f 2026-09-12 |
| Also measured on |
|
| Version | 1 |
Purpose
A caller who floods the API gets 429 with a Retry-After before any handler runs (at fd8558f only a same-origin caller sees it as a 429: the middleware's short-circuit answers below the CORS layer, Gotchas), and a caller who floods a credential-issuing route gets it sooner (at fd8558f without the header: the envelope handler drops it on the dependency tiers, Gotchas), while a client whose analytics beacon shares an office IP with their own login is never locked out by their own traffic. The invariant that makes it trustworthy is that every counter is keyed on the client IP the trusted proxy reported (never the peer address and never the leftmost X-Forwarded-For entry) and lives in Redis shared by every worker, with a per-process dictionary only as the fallback when Redis is absent; and that the limiter fails open by decision, so a Redis outage slows nothing and blocks nothing, which is why the money-bearing and mail-sending routes carry their own fail-closed check instead.
When to use
Include it from every recipe that mounts a public or credential-issuing route, and from every recipe that deploys behind a reverse proxy. Trigger phrases: "too many requests", "429", "Retry-After", "brute force on login", "one mailbox flooded with links", "the client IP is the load balancer", "why did the office get locked out", "is this per worker or shared".
Do not use it for the per-workspace mail relay ceiling (api/routers/mail_router.py 59 to 140, which refuses to send at all without Redis and belongs to messaging-email-sms), for the gateway's spend cap (a 429 from the meter, ai-gateway, B58), for the browser proof-of-work on public forms (forms-leads/form-protection-challenge, B26), for the desk intake's in-process token bucket (services/service_desk/intake_check.py 27 and 62 to 72, per user per worker), or for the aurum and prima-chat variants (B22, B21). The per-domain magic-link budget of the desk sign-in is identity-auth/desk-domain-sign-in (B20); it counts rows, not Redis keys.
Stack assumptions
Pinned at fd8558f (2026-09-12, requirements.txt): fastapi==0.118.2, redis==6.4.0 (redis.asyncio, services/rate_limit_service.py line 40), uvicorn==0.37.0 (its ProxyHeadersMiddleware is part of the trust chain, Gotchas). The middleware is a Starlette BaseHTTPMiddleware (unified_middleware.py 21), mounted once at app_fastapi.py line 523. Foundations assumed: S4 (middleware order, mount_router), S5 (the auth routes this decorates), S7 (the three 429 shapes below; the Retry-After fix lands in its http_exception_handler), S9 (the _fallback_limits.clear() fixture pattern), S13 (the per-app heredoc that carries REDIS_URL).
Environment names and their fail behaviour (core/config.py):
| Name | Default | Behaviour |
|---|---|---|
RATE_LIMIT_ENABLED | true (804) | gates the middleware tier only (unified_middleware.py 43, 82); the route dependencies run regardless; false in production logs a warning and proceeds (817 to 828) |
RATE_LIMIT_IP_PER_MINUTE | 1000 (811) | the global per-IP ceiling on /api/* |
RATE_LIMIT_PUBLIC_IP_PER_MINUTE | 240 (815) | the stricter ceiling on PUBLIC_PREFIXES |
TRUSTED_PROXY_COUNT | 1 (47) | how many X-Forwarded-For hops are trusted; 0 means the peer address is the client (utils/net.py 40 to 44); set nowhere in alchemycloud main b3d34da (2026-09-11; git grep TRUSTED_PROXY_COUNT b3d34da prints nothing), so the default holds |
REDIS_ENABLED, REDIS_URL | true, built from REDIS_HOST/PORT/DB/password when unset (388 to 433) | the shared store; disabled or unreachable means the in-memory fallback, per worker |
PRIMA_SIGNUP_MAX_PER_IP_PER_HOUR, PRIMA_SIGNUP_MAX_PER_EMAIL_PER_HOUR | 5, 3 (698 to 699) | the signup abuse guard's hourly caps |
RATE_LIMIT_PER_MINUTE, RATE_LIMIT_PER_HOUR | 60, 1000 (805 to 806) | read only by the placeholder RateLimitDependency and the unmounted api/middleware/rate_limit.py; dead (Gotchas) |
The constants that are not configuration: MAX_REQUESTS = 30 per 60 s per IP per path and EMAIL_MAX_REQUESTS = 10 per 60 s per address per scope (api/dependencies/rate_limit.py 26 to 35, since e895e7d on 2026-09-05); _SIGNUP_RATE_WINDOW_SECONDS = 3600 (primaapi_router.py 543).
Data model
No table. The state is Redis keys with a TTL, one per bucket, written with INCR then EXPIRE on the first hit (rate_limit_service.py 334 to 340):
rate_limit:ip:<client_ip> INTEGER, TTL 60 s the global per-IP ceiling (middleware)
rate_limit:public_ip:<client_ip> INTEGER, TTL 60 s the public-prefix ceiling (middleware)
rate_limit:auth:<path>:<client_ip> INTEGER, TTL 60 s auth_rate_limit, e.g. rate_limit:auth:/api/auth/login:203.0.113.7
rate_limit:auth_email:<scope>:<email> INTEGER, TTL 60 s enforce_email_rate_limit, scope "magic-link" today
rate_limit:prima_signup:ip:<client_ip> INTEGER, TTL 3600 s the signup guard
rate_limit:prima_signup:email:<email> INTEGER, TTL 3600 s
The value is the count in the current fixed window; the window starts at the first hit and is not sliding. The fallback is RateLimitService._fallback_limits: Dict[str, {"count": int, "expires": float}] (line 32), one dictionary per worker process, swept of expired keys on every call (374 to 379).
What is not stored: the email in clear anywhere but the key (the key is the lowercased address; the logs carry it too, Gotchas); a ban list (the comment at 222 says a ban "could" be triggered and nothing does); any per-user or per-API-key counter on the live path (check_rate_limit, 68 to 132, the five-layer design, has no caller outside the unmounted api/middleware/rate_limit_middleware.py 78); the api_keys.rate_limit_tier column (models/api_key_model.py 36, default="standard"), which nothing reads at fd8558f (grep -rn rate_limit_tier --include='*.py' . hits the model, the baseline migration and the placeholder docstring in api/middleware/api_key_auth.py 160 to 163), so a builder copying the api_keys DDL from identity-auth/developer-api-keys-and-security-policy (B10) drops it.
The three 429 bodies (S7): the middleware answers {"error": "rate_limit_exceeded", "message": "Too many requests. Please slow down."} as a raw JSONResponse (unified_middleware.py 185 to 192, the middleware shape); a dependency raises HTTPException(429, detail=<string>), rendered by S7's http_exception_handler as the Labs envelope {error, code: "RATE_001", request_id, timestamp, details: null} on every path but one; under PRIMA_GATEWAY_PREFIX (app_fastapi.py 750 to 766, the two /api/prima/v1/accounts* routes) the same exception is rendered as the OpenAI-shaped object {"error": {"message", "type": "rate_limit_error", "param": null, "code": "RATE_001", "request_id"}}, the gateway shape, which F1 never sees and the developer portal's signup screen (F7) parses. Measured 2026-09-12 with TestClient(app_fastapi.app): the 6th POST /api/prima/v1/accounts is 429 with that object and "Too many signup attempts. Please try again later." as its message. All three are raised with Retry-After: <seconds>, floor 1; at fd8558f only the middleware's header reaches the client, because http_exception_handler rebuilds the dependency's 429 with the CORS headers alone (Gotchas), and the middleware's own 429 reaches a cross-origin client with nothing but that header (Gotchas).
API surface
The module owns no route; it decorates routes. Where each tier applies at fd8558f:
| Tier | Where | Key | Limit | Code and detail |
|---|---|---|---|---|
| Middleware, global | every /api/* path except /api/health* and /api/auth* (unified_middleware.py 81 to 86, 130, 164 to 165) | rate_limit:ip:<ip> | RATE_LIMIT_IP_PER_MINUTE per 60 s | 429 rate_limit_exceeded |
| Middleware, public | on top of the global tier for PUBLIC_PREFIXES (133 to 141): /api/scribe, /api/tracking, /api/f/, /api/public, /api/waitlist, /api/feedback, /api/commerce/self-service | rate_limit:public_ip:<ip> | RATE_LIMIT_PUBLIC_IP_PER_MINUTE per 60 s | 429 rate_limit_exceeded |
Depends(auth_rate_limit) | POST /api/auth/register (234), /login (436), /forgot-password (879), /reset-password (901), /resend-verification (921), /verify-email (955), /set-password (978), /request-magic-link (1007); POST /api/auth/webauthn/login/begin, /login/begin-discoverable, /login/complete, /login/complete-discoverable (webauthn_router.py 215 to 274) and GET /check (338); POST /api/prima/v1/accounts (639) and /accounts/verify (701) | rate_limit:auth:<path>:<ip> | 30 per 60 s | 429 "Too many authentication attempts. Please try again later." |
_magic_link_email_throttle | POST /api/auth/request-magic-link only (1001 to 1007) | rate_limit:auth_email:magic-link:<email> | 10 per 60 s | 429 "Too many requests for this email. Please try again later." |
_signup_abuse_guard | POST /api/prima/v1/accounts (585 to 636) | rate_limit:prima_signup:ip:<ip>, ...:email:<email> | 5 and 3 per 3600 s | 429 "Too many signup attempts. Please try again later.", in the gateway shape (app_fastapi.py 750 to 766), as is the auth_rate_limit 429 on the two /api/prima/v1/accounts* routes |
Auth dep on the decorated routes: none of them carries a user (they issue credentials), which is why the keys are IP and address, never sub. Tenancy: none; a rate-limit key knows no workspace. Ordering against validation, measured 2026-09-12 with TestClient(app_fastapi.app): FastAPI 0.118 decodes the JSON body before it solves any dependency, so a body that is not JSON answers 422 (json_invalid) and spends nothing (POST /api/auth/login with not json: 422, _fallback_limits stays empty); a well-formed body that fails Pydantic does spend the IP bucket, because auth_rate_limit and _signup_abuse_guard take only request and run before the model is built ({} thirty times, then 429: test_rate_limit_still_bites_a_cross_origin_caller relies on this, test_webauthn_multi_rp.py 769 to 790). _magic_link_email_throttle(request_data: MagicLinkRequest) (auth_router.py 1000 to 1004) takes the parsed model, so the mailbox bucket is spent only on a valid body ({} to request-magic-link: 422, the IP key present, no auth_email key). The signup guard reads the memoised body itself (request.json(), 599 to 604) to find the email before Pydantic sees it.
Response headers the middleware adds to every response it lets through: X-Request-ID and X-Response-Time (unified_middleware.py 103 to 104). No X-RateLimit-Remaining is emitted anywhere, although check_simple_limit returns remaining and reset (352 to 356); a new build surfaces them on the 200 path.
Application logic
UnifiedMiddleware.dispatch (unified_middleware.py 49 to 121), per request:
- On the first request only,
initialize_servicescallsrate_limit_service.initialize()whenRATE_LIMIT_ENABLED(41 to 47):redis.from_url(settings.REDIS_URL, socket_timeout=1, socket_connect_timeout=1)and onePING; success sets_initialized; any failure leavesredis_client = Noneand_initialized = False(rate_limit_service.py34 to 66). - A request id is minted and put on
request.state; timing starts; memory pressure is checked and logged. - If the path starts with
/api/and not/api/health:_check_rate_limit(143 to 197). Inside:get_client_ip(request); anyAUTH_PREFIXESpath returnsNoneuntouched (164 to 165);check_simple_limit("rate_limit:ip:<ip>", RATE_LIMIT_IP_PER_MINUTE, 60); if allowed and the path is underPUBLIC_PREFIXES, the second bucket; if not allowed, the 429JSONResponsewithRetry-After. Any exception in the block is logged and the request proceeds (193 to 195). call_next; the two headers; the response log line; memory stats for slow or AI paths.
get_client_ip (utils/net.py 29 to 67): trusted = max(0, TRUSTED_PROXY_COUNT); with 0, the peer; without an X-Forwarded-For header, the peer; else split on commas, take the entry at index len(parts) - trusted (clamped to 0), which for one trusted hop is the rightmost entry, the one the trusted proxy itself appended and the client cannot forge; if that entry is not an IP literal, the peer.
check_simple_limit(key, limit, window) (rate_limit_service.py 415 to 432): initialise if not yet; dispatch on redis_client. Redis path (318 to 361): INCR; on 1, EXPIRE window; read TTL; a missing TTL is re-armed (the self-heal for a key that lost its expiry, comment 327 to 333); count > limit is a denial carrying retry_after = ttl; any exception is logged and allowed. Memory path (363 to 413): sweep expired entries; a new key is created at count 1 and allowed; count >= limit is a denial with retry_after = expires - now; else increment. Both paths therefore admit exactly limit requests per window and refuse the next.
auth_rate_limit(request) (api/dependencies/rate_limit.py 38 to 60): key rate_limit:auth:{request.url.path}:{client_ip}, check_simple_limit(..., 30, 60), 429 with Retry-After = max(1, retry_after) on denial. enforce_email_rate_limit(email, scope) (63 to 90): strip and lowercase; a blank address is a no-op (so anti-enumeration answers keep their shape); key rate_limit:auth_email:{scope}:{email}, 10 per 60 s. _signup_abuse_guard (primaapi_router.py 585 to 636): read the email from the memoised body, run the invite or allowlist gate when it has an @, then the per-IP and (when there is an email) per-email hourly buckets in that order; the log line names the bucket and the IP and never the email (626 to 629).
Middleware order at fd8558f (app_fastapi.py 446 to 535, added in this order): CORSMiddleware (446), the public-CORS function (483), SecurityHeadersMiddleware (507), TrustedHostMiddleware in production (511), ErrorLoggingMiddleware (518), UnifiedMiddleware (523), WorkspaceMiddleware (528). add_middleware inserts at the outside, so at runtime the last added runs first (S4 §7): a request passes WorkspaceMiddleware, then UnifiedMiddleware, then error logging, host check, security headers, and reaches CORSMiddleware last before routing. app.user_middleware outermost-first, read 2026-09-12: WorkspaceMiddleware, UnifiedMiddleware, ErrorLoggingMiddleware, SecurityHeadersMiddleware, BaseHTTPMiddleware (the public-CORS function), CORSMiddleware. The consequence for this module: the limiter's short-circuit at _check_rate_limit (185 to 192) returns from the second layer, above everything that would add Access-Control-Allow-Origin, the security headers or X-Request-ID (the two headers at 103 to 104 are added only after call_next), so the middleware-tier 429 carries Retry-After and nothing else (Gotchas).
Invariants
- The client IP is
get_client_ip, neverrequest.client.host, in every counter (unified_middleware.py159,rate_limit.py43,primaapi_router.py610);request.client.hostappears in the limiter's own file only in the request log line (73). - One trusted hop means the rightmost
X-Forwarded-Forentry; a client that sends its ownX-Forwarded-Formoves its forged entries left and changes nothing (utils/net.py50 to 61). /api/auth/*is outside the volumetric tiers (AUTH_PREFIXES, 123 to 130, since e895e7d), so no amount of unrelated traffic from the same IP can 429 a login; the 15 credential-issuing routes carry the per-route bucket; every other/api/authroute is unlimited at fd8558f (Gotchas). On a build to this recipe every anonymous route under the prefix carriesauth_rate_limit.- A bucket admits
limitrequests per fixed window and refuses the next, on both stores (current_count > limitat 342;entry['count'] >= limitafter the create-at-1 at 396). - Every denial is raised with
Retry-Afterwith a floor of 1 second (rate_limit.py59 and 89,unified_middleware.py191,primaapi_router.py632); at fd8558f the envelope handler drops it on the three dependency tiers, so only the middleware's reaches the client (Gotchas). On a build to this recipe the client sees it on all four, and a cross-origin script can read it becauseRetry-Afteris inexpose_headers(Security & tenancy). - A 429 from any tier carries the same CORS, security and
X-Request-IDheaders as the 200 it replaces. At fd8558f the middleware tier breaks this (its 429 carriesRetry-Afteralone, so a cross-origin browser reports a network error and never sees the status, Gotchas); on a build to this recipe the short-circuit response is built with those headers. - Authenticated
/api/authroutes (/logout,GETandPATCH /me,/workspaces,/change-password,/ws-ticket, the five passkey management routes) are outside every tier by decision: a session is the ceiling, and/change-passwordverifies the current password under a session that a flood of wrong guesses cannot obtain. The five anonymous undecorated routes are the defect (Gotchas), not these. - The limiter fails open: an exception in the Redis path (358 to 361) or in the middleware block (193 to 195) admits the request; the only fail-closed limiter on the tree is the mail relay's, which is not this module.
- The email throttle is per scope and per address, independent of IP, so rotating IPs does not enlarge a mailbox's budget, and a NAT full of real devices does not shrink it (the docstring at 63 to 73).
auth_rate_limitand_signup_abuse_guardrun before Pydantic validation: a well-formed body that fails validation still counts. A body that is not JSON answers 422 before any dependency and spends nothing, and_magic_link_email_throttleneeds the parsed model, so the mailbox bucket is spent on valid bodies only (API surface).- No counter is keyed on a user id or an API key on the live path; the five-layer
check_rate_limitis unreachable.
Frontend
identity-auth/ui/auth-screens the 429 state on LoginAlchemy, ForgotPassword, MagicLink request and the
passkey flows: readFailure parses the two shapes it can meet (the
middleware's {error, message} and the envelope's {error, code}) and
renders the Retry-After as copy ("in N Sekunden erneut versuchen");
the button stays disabled for that long. At fd8558f the middleware
shape is reachable same-origin only (its 429 has no CORS headers,
Gotchas) and the Retry-After is readable by neither shape from
another origin (not in expose_headers, Security & tenancy)
identity-auth/ui/developer-portal-auth-pages the signup screen's 429 on POST /api/prima/v1/accounts, which is the
gateway shape {error: {message, type, code}} and not the envelope;
its Gotcha at line 209 records the expose_headers trap first
monitoring-ops/ui/admin-dashboard-and-system-logs (F85, not a file yet) shows the limiter's warnings in the system-logs console (the Rate limit exceeded for IP and Auth rate limit exceeded lines, unified_middleware.py 184, rate_limit.py 53 and 85) and the redis entry of /health/detailed (B166), which at fd8558f is "healthy" if settings.REDIS_ENABLED else "disabled" (api/routers/health_router.py 101): configuration, not liveness, so it says healthy in the exact case this recipe cares about (Redis configured, unreachable, per-worker buckets). Whether the fallback is in use is visible only in the boot log line Redis unavailable (...), using in-memory fallback (rate_limit_service.py 65) and on no endpoint (Gotchas).
Minimum admin and debug surface: the three warning lines above; the boot log line for the fallback, or on a build to this recipe a rate_limit_backend: redis|memory field on GET /health/detailed (at fd8558f the endpoint reports only whether Redis is configured, Gotchas); redis-cli --scan --pattern 'rate_limit:*' on the store, which lists every live bucket with its count and TTL; rate_limit_service.reset_limit(key) (481 to 496) for an operator who must unblock one caller, callable from a shell and from no route.
Security & tenancy
Auth dependency: none of its own; the decorated routes are anonymous by nature. Workspace derivation: none. Fail direction: open, by decision (the inventory's cross-cutting rule, 2026-09-12: "rate limiting fails OPEN"), on three seams: Redis down at boot (memory fallback, per worker), a Redis error mid-request (allowed), an exception in the middleware block (allowed). RATE_LIMIT_ENABLED=false removes the middleware tiers and leaves the route dependencies.
What the fallback costs: with WEB_CONCURRENCY=2 (repo-root Dockerfile 77 and 117; deploy/infomaniak/.env.infomaniak.example 12) two workers each hold their own dictionary, so every ceiling is effectively doubled while Redis is absent (30 per minute per IP per path becomes up to 60), and a restart resets every bucket. The inventory's "x4 on 4 workers" is the WORKERS: int = 4 default in core/config.py line 41, which the Dockerfile does not read; measured 2026-09-12, the shipped process count is 2.
Attacker-controlled inputs: X-Forwarded-For (only the rightmost trusted hop is read; everything left of it is ignored); the request path (part of the auth key, so /api/auth/login and /api/auth/login/ are two buckets and a trailing slash doubles the budget; FastAPI redirects the slash variant with 307 before the dependency runs, which closes it for registered routes); the email in the body (lowercased into a key; a plus tag is not stripped here, only in the desk path, B20, so a+1@example.com and a+2@example.com are two mailbox buckets on request-magic-link while delivering to one inbox); the Host header (irrelevant to the key). The signup guard parses the body as JSON before validation and ignores a body it cannot parse (600 to 604), leaving the 422 to the endpoint.
Credentials at rest: none. Logging: the email throttle logs the normalised address at WARNING (rate_limit.py 85); the signup guard deliberately does not (627); a new build logs a fingerprint (B20's email_fingerprint) on both.
The trust chain, measured 2026-09-12: Caddy terminates TLS and appends the peer to X-Forwarded-For (repo-root deploy/infomaniak/Caddyfile.staging 120 to 141: no header_up, Caddy sets the forwarded headers itself); uvicorn runs with --proxy-headers --forwarded-allow-ips='*' (repo-root Dockerfile 117); with * uvicorn 0.37.0's ProxyHeadersMiddleware rewrites request.client.host to x_forwarded_for_hosts[0], the leftmost entry (uvicorn/middleware/proxy_headers.py 131 to 132), which the client chose. get_client_ip does not use it, so the counters are sound; everything else that reads request.client.host is reading an attacker-supplied string (Gotchas).
What a cross-origin caller can read of a 429, measured 2026-09-12: CORSMiddleware is built with expose_headers=["X-Total-Count", "X-Request-ID"] (app_fastapi.py 452), so even on a build with the header merge a browser script on another origin cannot read Retry-After from a dependency 429; F7's Gotcha (developer-portal-auth-pages.md 209) records the same trap from the client side, and this module owns the fix: a build adds Retry-After to that list (S4's CORS block), and the public-CORS function (483 to 505), which answers the PUBLIC_CORS_PREFIXES embeds, adds Access-Control-Expose-Headers: Retry-After, X-Request-ID on its way out. The middleware-tier 429 has the prior problem that it carries no Access-Control-Allow-Origin at all (Gotchas).
Tests
Backend (alchemy_labs/backend/tests/, run 2026-09-12 at fd8558f against the :5434 Postgres):
test_webauthn_multi_rp.py -k rate_limitandtest_primaapi_gateway.py -k "rate_limit or hourly_cap":1 failed, 3 passed in 6.62s. Passing:test_auth_endpoints_are_still_rate_limited(793 to 816: walks the route table and assertsauth_rate_limitis among the dependencies of the four passkey login routes andGET /check),test_per_ip_hourly_cap_returns_429andtest_per_email_hourly_cap_returns_429(test_primaapi_gateway.py341 to 371: monkeypatch the cap to a small number, post until 429, assertRetry-Afterpresent; the autouse_fresh_limiterfixture at 331 to 339 clears_fallback_limitsbefore and after). Those two assert the header on a bareFastAPI()that mounts the router alone (signup_client, 253 to 259), which has nohttp_exception_handler; againstapp_fastapi.appthe same assertion fails, because the handler drops the header (Gotchas), so they prove the raise and not the response. Failing:test_rate_limit_still_bites_a_cross_origin_caller(769 to 790), which posts eleven invalid bodies and asserts the eleventh is 429; since e895e7d raisedMAX_REQUESTSfrom 10 to 30 the eleventh is 422 (Gotchas).tests/conftest.py537 to 545 clears_fallback_limitsaround every test, which is the S9 pattern a new service copies: the singleton's dictionary is process state, and a test that leaves a bucket behind fails the next file.tests/unit/test_security.py::TestRateLimiter(167 to 215) tests aRateLimiterclass fromlayers/common/python/utils.py, a Lambda-layer artefact loaded by file path (10 to 30); it is not this module and passes for reasons of its own.- Known-answer vectors, for a rebuilt
get_client_ip:TRUSTED_PROXY_COUNT=1, peer10.0.0.5,X-Forwarded-For: 198.51.100.9, 203.0.113.7yields203.0.113.7;TRUSTED_PROXY_COUNT=2on the same header yields198.51.100.9;TRUSTED_PROXY_COUNT=1withX-Forwarded-For: not-an-ipyields10.0.0.5;TRUSTED_PROXY_COUNT=0with any header yields10.0.0.5; no header yields10.0.0.5. One test exists at fd8558f:tests/unit/test_capi_identity_preservation.py::TestClientIpExtraction::test_get_client_ip_does_not_use_spoofable_first_entry(330 to 369, since e3d5e79 on 2026-07-28) feeds198.51.100.5, 203.0.113.5, 10.0.0.1withTRUSTED_PROXY_COUNTat its default and asserts only that the leftmost entry is not returned (ip in ("203.0.113.5", "10.0.0.1")), a negative assertion on one vector;grep -rn get_client_ip tests/hits that file and the import note intest_security.pyline 15 and nothing else. The other four rows (two hops, a non-IP literal, zero hops, no header) and the exact answer on the first have no test; a new build addstests/unit/test_client_ip.pywith the five rows. - What a new build adds for the buckets: the 31st
POST /api/auth/loginfrom one IP within a minute is 429 and the 31st from another IP is not;/api/auth/loginand/api/auth/registerare separate buckets; the 11threquest-magic-linkfor one address from eleven IPs is 429 and the 11th for a second address is 202; a/api/scribe/...flood does not touch the/api/auth/loginbudget; withREDIS_ENABLED=falsethe same assertions hold in one process.
Hermetic throughout: every test above runs on the memory fallback (REDIS_ENABLED is off in tests/conftest.py's settings), so the Redis path (_check_limit_redis, 318 to 361) has no test at fd8558f; a new build runs the bucket assertions once more against a throwaway Redis in docker-compose.test.yml. Playwright: none at fd8558f (frontend/e2e/auth.spec.ts is stale and asserts nothing about 429: grep -n -i "429\|rate\|retry" frontend/e2e/auth.spec.ts prints nothing, and F1's Gotchas record that no spec covers /login as built); a new build adds the 31st-submit wait state to F1's spec, and the counting is tested here, never in the browser.
Deploy & ops
Config keys per app heredoc (S13): REDIS_URL (one line, the managed Redis with its password; deploy/infomaniak/.env.infomaniak.example 57 to 58) is the only one of this module's keys the Labs heredoc sets in alchemycloud main b3d34da (scripts/distribute-env.sh 234 and 375; git grep for RATE_LIMIT_ENABLED, REDIS_ENABLED and TRUSTED_PROXY_COUNT at b3d34da prints nothing), so RATE_LIMIT_ENABLED=true and REDIS_ENABLED=true ride on their core/config.py defaults (804, 388 to 433); a new service writes all three explicitly; optionally RATE_LIMIT_IP_PER_MINUTE and RATE_LIMIT_PUBLIC_IP_PER_MINUTE, and TRUSTED_PROXY_COUNT only when the hop count changes (one Caddy in front of uvicorn is the default 1; a second proxy, a CDN or a Cloudflare tunnel in front of Caddy makes it 2, and forgetting that makes every client share Caddy's bucket). The uvicorn flags --proxy-headers --forwarded-allow-ips='*' stay: they are what makes request.url reconstruct as https:// for OAuth callbacks (the Dockerfile comment 107 to 116); they do not feed this module. The CORS block is part of this module's deploy surface: expose_headers must name Retry-After for the countdown to be readable cross-origin (Security & tenancy).
Migrations order: none. Scheduler jobs: none; keys expire by TTL. Argus checks: the boot log line Redis unavailable (...), using in-memory fallback (rate_limit_service.py 65) absent from the last start, or on a build to this recipe rate_limit_backend: redis on GET /health/detailed (at fd8558f the endpoint's redis: healthy means configured, not answering, and cannot fire on the fallback, Gotchas); when the fallback is in use every ceiling is per worker and a deploy resets it; a synthetic probe that posts 31 bad logins from one IP within a minute and expects the last to be 429 with Retry-After is the only end-to-end check of the auth tier, and it must run against staging, never against production, because it spends a real bucket. Rollback: configuration only; a wrong TRUSTED_PROXY_COUNT is fixed by redeploying the value, and a stuck bucket by reset_limit or by waiting out the TTL (60 s, 3600 s for the signup guard).
Gotchas
- 2026-09-12 (measured at fd8558f, D8 gotcha, red test):
tests/test_webauthn_multi_rp.py::test_rate_limit_still_bites_a_cross_origin_caller(769 to 790) asserts the 11th request is 429; the ceiling has been 30 since e895e7d (2026-09-05), so the assertion readsassert 422 == 429. The fix isrange(31)andstatuses[:30] == [422] * 30, or importingMAX_REQUESTS. - 2026-09-12 (measured at fd8558f, D8 gotcha):
http_exception_handler(app_fastapi.py724 to 788) builds both of itsJSONResponses withheaders=_error_cors_headers(request)(754 and 779) and never readsexc.headers, so theRetry-Afterthatauth_rate_limit,enforce_email_rate_limitand_signup_abuse_guardraise (rate_limit.py59 and 89,primaapi_router.py632) never reaches the client; only the middleware's rawJSONResponse(unified_middleware.py185 to 192) carries it. Measured withTestClient(app_fastapi.app): the 31stPOST /api/auth/login {}is 429 with noretry-afteramong its headers (the security headers,x-request-idandx-response-timeare there), body{"error":"Too many authentication attempts. Please try again later.","code":"RATE_001",...}; the 11threquest-magic-linkfor one address is 429 with noretry-after; the 6thPOST /api/prima/v1/accountslikewise; four/api/forms/templatesunderRATE_LIMIT_IP_PER_MINUTE=3give 429 withretry-after: 59. The fix isheaders={**_error_cors_headers(request), **(exc.headers or {})}on both return sites. Five sibling recipes assert the header on a dependency 429 against the reference app and are wrong at fd8558f until that merge lands:ui/auth-screens.md146 (which also names the codeRATE_LIMIT_EXCEEDED; the wire value isRATE_001, the enum's value),registration-with-email-verification.md245,magic-link-login.md249,password-reset.md231 andapi-only-self-serve-signup.md237 (whose two hourly-cap tests pass only on the bare-routersignup_client, Tests). This recipe is the measured one; those lines are corrected in their own recipes. - 2026-09-12 (measured at fd8558f, D8 gotcha):
AUTH_PREFIXES(e895e7d) takes every path under/api/authout of the volumetric tiers, and only the 15 decorated routes have a bucket of their own, so every undecorated route under the prefix has no limiter of any tier. Anonymous ones first:POST /api/auth/verify-magic-link(auth_router.py1126, redeems a token),POST /refresh(642, redeems a refresh token),GET /cli-login(1510),GET /api/auth/infomaniak/startand/callback(auth_infomaniak_router.py38 and 53, mounted at/api/auth,app_fastapi.py821;grep -c auth_rate_limitprints0); andPOST /api/auth/dev-login(auth_router.py1684 to 1686), registered only whenENVIRONMENT=development, which mints a session for the first admin with no limiter and is what the AUTHORING.md harness relies on, so it stays unlimited and stays out of production. Measured withTestClient(app_fastapi.app): 35POST /api/auth/verify-magic-linkfrom one IP inside a minute and no 429 among them. Authenticated ones:/logout(671),GET /me(697),/workspaces(733),PATCH /me(823),/change-password(845),/ws-ticket(1623), and the five undecorated passkey routes (webauthn_router.py167, 186, 296, 309, 317). Sibling recipes record it per route (magic-link-login.md,infomaniak-oidc-login.md). The fix isDepends(auth_rate_limit)on the five anonymous routes (30 per minute per IP per path, the same order as the login routes), or narrowingAUTH_PREFIXESto the decorated paths so the rest fall back under the 1000 per minute ceiling; the first is the one this recipe specifies. - 2026-09-12 (measured at fd8558f, D8 gotcha):
GET /health/detailedreports"redis": "healthy" if settings.REDIS_ENABLED else "disabled"(api/routers/health_router.py101;grep -n -i redison the file prints that line alone), so it never probes Redis and sayshealthywhile every worker runs on its own dictionary. The inventory row B166 records the same ("reports config presence not liveness"). The only evidence of the fallback at fd8558f is the boot log lineRedis unavailable (...), using in-memory fallback(rate_limit_service.py65). The fix is aPINGbehind the entry, or arate_limit_backend: redis|memoryfield read fromrate_limit_service.redis_client is not None, which is what the Argus check above reads. - 2026-09-12 (measured at fd8558f, D8 gotcha):
RateLimitDependency(api/dependencies.py425 to 447) is a class whose__call__ends inpass;rate_limit_strictbuilt from it (457 to 460) is declared onPOST /api/accounting/drive/watchand/sync(accounting_drive_router.py30 and 46), which therefore believe they are limited to 10 per minute and are limited to nothing. The fix isDepends(auth_rate_limit)there (30 per minute per IP per path, which is the same order) and deleting the class,rate_limit_defaultandRATE_LIMIT_PER_MINUTE/RATE_LIMIT_PER_HOURwith it. - 2026-09-12 (measured at fd8558f, D8 gotcha): two middleware files implement a limiter and neither is mounted:
api/middleware/rate_limit_middleware.py(200 lines, the only caller of the five-layercheck_rate_limit) andapi/middleware/rate_limit.py(readsRATE_LIMIT_PER_MINUTE).grep -rn "RateLimitMiddleware" app_fastapi.pyprints nothing. A third placeholder,api/middleware/api_key_auth.py::check_rate_limit(146 to 172), ends inreturn True # For now, always allowand has no caller (grep -rn "check_rate_limit(" api servicesfinds only the unified middleware's own method, the public-forms and form-protection helpers, and the unmounted file). Delete all three andcheck_rate_limitthrough_check_cost_based_limit(rate_limit_service.py68 to 292), which leavescheck_simple_limit,reset_limitand the two store paths. - 2026-09-12 (measured at fd8558f):
initialize()sets_initializedonly on a successfulPING(58). WithREDIS_ENABLED=trueand Redis unreachable, everycheck_simple_limitcall re-entersinitialize()(428 to 429), builds a client and waits up tosocket_connect_timeout=1for thePINGbefore falling back: one second added to every limited request, and to every/api/*request through the middleware, for as long as Redis is down. The fix is to set_initialized = Trueon the failure branches too and re-probe on a timer. - 2026-09-12 (measured at fd8558f):
auth_rate_limit's docstring (39 to 42) and the signup guard's (588) still say 10 per minute per IP; the constant is 30. The commit message of e895e7d is the record. - 2026-09-12 (measured at fd8558f):
enforce_email_rate_limitnames three callers in its docstring (magic link, password reset, verification resend; 64 to 66); onlyrequest-magic-linkcalls it (grep -rn enforce_email_rate_limit api/hitsauth_router.py1004 alone)./forgot-passwordand/resend-verificationare per IP only, so one mailbox can receive 30 reset mails a minute per source IP. A new build adds the throttle with scopespassword-resetandverificationto both. - 2026-09-12 (measured at fd8558f): the middleware's request log line (73) and
create_session'sip_address(auth_router.py1228 and every otherrequest.client.hostcall site) record the value uvicorn's proxy middleware derived, which under--forwarded-allow-ips='*'is the leftmostX-Forwarded-Forentry, i.e. whatever the client wrote.sessions.ip_addressis therefore not evidence of where a session came from. A new build replaces everyrequest.client.hostwithget_client_ip(request)(grep -rn "request.client.host" api services | wc -lprints45at fd8558f) or passes--forwarded-allow-ipsthe proxy's address instead of*. - 2026-09-12 (measured at fd8558f): the middleware limiter was a no-op until 3d39ef2 (2026-07-27): the 2026-06-12 audit's item 4 (
operations/security-audit-2026-06-12.mdline 19, "TEMPORARILY DISABLED", matching only/api/v1/*) describes the file before that commit. The wiki row is stale; the code at fd8558f is what this recipe describes. - 2026-09-05 (e895e7d, measured in the commit and at fd8558f): before that commit
/api/authwas inside the shared per-IP counter, and a client's own Scribe beacon on their own site, plus several devices behind one office NAT, spent the budget and 429'd that client's login.AUTH_PREFIXESand the 10 to 30 change are the fix; the per-email throttle is what kept mailbox-flood protection when the per-IP ceiling loosened. - 2026-09-12 (measured at fd8558f): the Redis path's denial carries
retry_after = ttl, which is never negative (337 to 340 replace a missing or negative TTL withwindowbefore the comparison, and anEXPIREexception takes the fail-open branch at 358 to 361) but can be0when the TTL is read in the key's last second; the memory path'sretry_after = expires - nowcan round to0the same way. Themax(1, ...)floors at every raise site cover both. - 2026-09-12 (measured at fd8558f, D8 gotcha): the middleware-tier 429 reaches a cross-origin browser as a CORS failure, not as a 429.
UnifiedMiddlewareis the second layer from the outside (app.user_middlewareat fd8558f:WorkspaceMiddleware, UnifiedMiddleware, ErrorLoggingMiddleware, SecurityHeadersMiddleware, BaseHTTPMiddleware (public CORS), CORSMiddleware), so theJSONResponseits_check_rate_limitreturns (185 to 192) never passesCORSMiddleware, the public-CORS function orSecurityHeadersMiddleware, anddispatchsetsX-Request-IDonly aftercall_next(103). Measured withTestClient(app_fastapi.app),RATE_LIMIT_IP_PER_MINUTE=3,Origin: https://prima.li(an allowed origin), fourGET /api/forms/templates: statuses400, 400, 400, 429; the three 400s carryaccess-control-allow-origin,x-content-type-options,x-frame-optionsandx-request-id; the 429's headers among those andretry-afterareretry-after: 59alone. The same four against/api/f/...with a customer origin: three 404s with the public-CORSaccess-control-allow-origin, then a bare 429. S7's table already records the request-id half (error-envelope.md150: neither body nor header). The surface this hits is exactlyPUBLIC_PREFIXES(Scribe beacons, form embeds, the self-service cancel), whose callers are always cross-origin, so at fd8558f a client site that trips the 240 per minute ceiling seesTypeError: Failed to fetchand noRetry-After. The fix is to build the short-circuit response with the headers the outer layers would have added:headers={**cors_headers_for(request), "Retry-After": str(max(1, retry_after)), "X-Request-ID": request.state.request_id}, wherecors_headers_forreflects the origin the way_error_cors_headersdoes (app_fastapi.py691 to 707) and, on aPUBLIC_CORS_PREFIXESpath, the way the public-CORS function does (497 to 503); that helper cannot be imported fromapp_fastapi.pyinto the middleware (circular), so it moves tocore/cors.pyand both call it. Running the limiter insideCORSMiddlewarewould also fix it and is not the answer for a new service: S4 wants the request-context middleware outermost. - 2026-09-12 (measured at fd8558f): the middleware tiers do not cover paths outside
/api/(the hosted form at/f/..., the booking shell,/.well-known/), so a flood of/f/renders is unlimited by this module; the form's PoW (B26) is the ceiling there.
Reference implementation
alchemy_labs, branch d1a-token-source at fd8558f (2026-09-12; backend identical to feat/service-desk b1089b6). Paths relative to backend/ unless marked repo-root:
api/dependencies/rate_limit.py(90 lines): constants (26 to 35),auth_rate_limit(38 to 60),enforce_email_rate_limit(63 to 90). Last change e895e7d (2026-09-05).services/rate_limit_service.py(554 lines):initialize(34 to 66), the dead five-layer path (68 to 292),_check_limit(294 to 316),_check_limit_redis(318 to 361),_check_limit_memory(363 to 413),check_simple_limit(415 to 432),get_usage_stats(434 to 479),reset_limit(481 to 496),get_all_limits(498 to 550), the singleton (554).utils/net.py:get_client_ip(29 to 67); the SSRF helpers that share the file (71 to 133) belong to the connections and scraper recipes.api/middleware/unified_middleware.py(197 lines):initialize_services(41 to 47),dispatch(49 to 121),AUTH_PREFIXES(130),PUBLIC_PREFIXES(133 to 141),_check_rate_limit(143 to 197). Last change bd81eb7 (2026-09-11, the self-service prefix).app_fastapi.py446 to 535: the middleware stack (452 theexpose_headerslist withoutRetry-After; 483 to 505 the public-CORS function); 523 the mount; 691 to 707_error_cors_headers; 724 to 788http_exception_handler(750 to 766 the gateway-shape branch; the two return sites at 754 and 779 that dropexc.headers); 821 the OIDC router mounted under/api/auth.api/routers/health_router.py101: theredisentry of/health/detailed(configuration, not liveness).api/routers/auth_infomaniak_router.py38 and 53: two/api/authroutes with no limiter.core/config.py:TRUSTED_PROXY_COUNT(44 to 47),REDIS_*(388 to 433),RATE_LIMIT_*(803 to 828),PRIMA_SIGNUP_MAX_*(698 to 699).api/routers/auth_router.py: the eightDepends(auth_rate_limit)sites (234, 436, 879, 901, 921, 955, 978, 1007),_magic_link_email_throttle(1000 to 1004),dev-login(1684 to 1686, development only).api/routers/webauthn_router.py: 215, 232, 252, 274, 338.api/routers/primaapi_router.py:_SIGNUP_RATE_WINDOW_SECONDS(543),_signup_abuse_guard(585 to 636), the two decorated routes (639, 701). These two signup routes are the part of the file that stays in Labs by design (prima/prima/api/v1.py12 to 14 at cd2c248:/accountsand/accounts/verifySTAYED in Labs); the chat and embeddings gateway in the same file (196, 473) is the legacy copy, and its meter's 429 is B58's under D3.api/dependencies.py425 to 460: the placeholder (to delete);api/routers/accounting_drive_router.py30 and 46: its two callers.api/middleware/rate_limit_middleware.py,api/middleware/rate_limit.py: unmounted (to delete);api/middleware/api_key_auth.py146 to 172: thereturn Trueplaceholder (to delete);models/api_key_model.py36: the unreadrate_limit_tiercolumn.- repo-root
Dockerfile77 and 117: the worker count and the uvicorn flags (there is nobackend/Dockerfile;backend/holds onlyDockerfile.runpod); repo-rootdeploy/infomaniak/Caddyfile.staging120 to 141: the proxy's header behaviour. tests/unit/test_capi_identity_preservation.py330 to 369: the oneget_client_iptest.- Tests as listed under Tests.
Deviations of the reference from this recipe, all dated 2026-09-12: the red 11-request test; the placeholder dependency on two accounting routes; two unmounted limiter files and the dead five-layer path; the one-second re-initialisation penalty while Redis is down; the stale "10/min" docstrings; the per-email throttle on one route instead of three; request.client.host recorded as a session address; no X-RateLimit-* headers on success; the address logged in clear by the email throttle; Retry-After dropped by the envelope handler on the three dependency tiers (the header merge on both return sites); five anonymous /api/auth routes with no limiter of any tier (auth_rate_limit on the anonymous five; the eleven authenticated ones stay unlimited by decision, Invariants); /health/detailed reporting Redis as configured rather than answering (the rate_limit_backend field); the middleware-tier 429 answered above the CORS and security-header layers with Retry-After alone (the short-circuit built with the reflected CORS headers and X-Request-ID); Retry-After absent from expose_headers (one entry in S4's CORS block and on the public-CORS function); a third return True limiter placeholder in api_key_auth.py and the unread api_keys.rate_limit_tier column (both deleted).
Acceptance checks
Run from alchemy_labs/backend at fd8558f with the :5434 Postgres up, or from a new service built to this recipe. Expected observations are exact.
python -m pytest tests/test_webauthn_multi_rp.py -k rate_limit tests/test_primaapi_gateway.py -k "rate_limit or hourly_cap" -q --no-cov -o addopts=""prints1 failed, 3 passedat fd8558f and4 passedonce the 11-request test readsMAX_REQUESTS.grep -rn "Depends(auth_rate_limit)" api/routers | wc -lprints15(8 inauth_router.py, 5 inwebauthn_router.py, 2 inprimaapi_router.py) at fd8558f;for f in auth_router webauthn_router primaapi_router auth_infomaniak_router; do echo -n "$f "; grep -c "Depends(auth_rate_limit)" api/routers/$f.py; doneprints8 5 2 0at fd8558f and11 5 2 2on a build to this recipe (refresh,verify-magic-link,cli-login,infomaniak/start,infomaniak/callbackadded);grep -rn "rate_limit_strict\|rate_limit_default" api/routers | wc -lprints3at fd8558f (the import and the two decorators inaccounting_drive_router.py) and0on a build to this recipe;grep -n expose_headers app_fastapi.pyprints one line namingX-Total-CountandX-Request-IDat fd8558f and the same line withRetry-Afteradded on a build.grep -n "request.client.host" api/middleware/unified_middleware.py api/dependencies/rate_limit.py utils/net.py services/rate_limit_service.pyprints four lines, two of them docstrings (rate_limit.py7,net.py8) and two code (unified_middleware.py73, the request log line;net.py41, the peer fallback): no counter reads the peer.grep -rn "request.client.host" api services | wc -lprints45, the sites the last Gotcha but three names.python -c " from types import SimpleNamespace as N import core.config as c; from utils.net import get_client_ip def req(xff, peer='10.0.0.5'): return N(client=N(host=peer), headers={'x-forwarded-for': xff} if xff else {}) c.settings.TRUSTED_PROXY_COUNT=1; print(get_client_ip(req('198.51.100.9, 203.0.113.7')), get_client_ip(req('not-an-ip')), get_client_ip(req(None))) c.settings.TRUSTED_PROXY_COUNT=2; print(get_client_ip(req('198.51.100.9, 203.0.113.7'))) c.settings.TRUSTED_PROXY_COUNT=0; print(get_client_ip(req('198.51.100.9, 203.0.113.7'))) "prints203.0.113.7 10.0.0.5 10.0.0.5, then198.51.100.9, then10.0.0.5.- With the app on 8020 (the harness runs one worker on the memory fallback):
for i in $(seq 1 31); do curl -s -o /dev/null -w "%{http_code} " -X POST -H "Content-Type: application/json" -d '{}' http://127.0.0.1:8020/api/auth/login; done; echoprints thirty422followed by one429;curl -s -i -X POST -H "Content-Type: application/json" -d '{}' http://127.0.0.1:8020/api/auth/login | grep -i "retry-after\|Too many"prints one line at fd8558f, the body's"error":"Too many authentication attempts. Please try again later.", and noretry-after(the handler drops it, Gotchas); on a build with the header merge it prints two,retry-after: <1..60>first; the same 31 requests against/api/auth/registerin the same minute print thirty422and one429of their own (a separate bucket). - In the same minute:
curl -s -o /dev/null -w "%{http_code}" -X POST -H "Content-Type: application/json" -d '{"email":"nobody@example.com","password":"x"}' -H "X-Forwarded-For: 203.0.113.99" http://127.0.0.1:8020/api/auth/loginprints401or422, not429: a different reported client is a different bucket. The header is honoured becauseget_client_ipreadsX-Forwarded-Foritself withTRUSTED_PROXY_COUNT=1and no proxy stands in front of the harness to append the real peer (tooling/verify/labs-local.sh160 starts uvicorn without--proxy-headers), so the client's own entry is the rightmost one; behind Caddy the same forged header is shifted left and ignored. for i in $(seq 1 11); do curl -s -o /dev/null -w "%{http_code} " -X POST -H "Content-Type: application/json" -d '{"email":"flood@example.com"}' -H "X-Forwarded-For: 203.0.113.$i" http://127.0.0.1:8020/api/auth/request-magic-link; done; echoprints ten202and one429whose body says"Too many requests for this email. Please try again later.": eleven source addresses, one mailbox budget.for i in $(seq 1 1001); do curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8020/api/forms/templates; done | sort | uniq -cshows1000of one status and1of429, and the 429 body is{"error":"rate_limit_exceeded","message":"Too many requests. Please slow down."};curl -s -i -H 'Origin: https://prima.li' http://127.0.0.1:8020/api/forms/templates | grep -i "^access-control-allow-origin\|^x-request-id\|^retry-after\|^x-frame-options"on the 1001st request printsretry-afteralone at fd8558f (the three other lines are present on the 1000 before it) and all four lines on a build to this recipe; repeating the loop immediately against/api/auth/meshows no429at fd8558f, which is the hole and not a guarantee:/meis outside the volumetric tier and carries no bucket of its own, so 1001 requests answer401(Gotchas). On a build to this recipe the same loop againstPOST /api/auth/verify-magic-linkwith{"token":"x"}prints thirty400and then429.- With Redis reachable (
REDIS_ENABLED=true,REDIS_URLset) and two workers: the checks in 5 and 7 hold with the same counts;redis-cli --scan --pattern 'rate_limit:auth:*'lists one key per path and IP with a TTL at most 60; stopping Redis and repeating 5 still prints one429per worker's own count and every request answers within the second (on a build with the re-initialisation fix) or one second slower (at fd8558f).
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.