On this page
ADR-0019: The global rate limit runs after authentication and fails open
Status: Accepted (WP-02). Amended by WP-70a: a limiter outage now falls back to per-process limits instead of failing open (amended 2026-11-10).
Context
The plan placed the global rate limit before authentication in the middleware stack. But the limits are per principal for authenticated requests (600 per minute) and per IP for anonymous ones (120 per minute); the principal is only known after authentication.
Decision
- Run the global API rate limit directly after the authentication middleware. The middleware order is: request id → trace and metrics → panic catcher → security headers → body limit → timeout → CORS → authentication with the global rate limit → CSRF → authentication gate → router.
- Use fixed windows per
(bucket, key). With Valkey configured, one Lua script performsINCRandPEXPIREatomically; without it an in-memory limiter is used (development and tests). Per-account login keys are SHA-256 hashes of the normalized login, so no email address is stored in Valkey. - If the limiter backend is unavailable, the request is allowed and the failure is logged ("fail open").
- The global API buckets apply to
/api/paths only; webhooks and the registry token realm have their own buckets.
Consequences
- Authentication work (one session or token lookup) happens before an anonymous flood is limited; that cost is bounded by the body limit and timeout layers.
- A Valkey outage removes rate limiting. Login brute force is still bounded by the account lockout stored in PostgreSQL, but other buckets (registration, password reset, API) are unprotected during the outage. This is listed as an open item in the threat model; an alert on limiter errors does not exist yet.
- Configuration requires
cache.urlin production so that limits are shared across API replicas.
Amendment, 2026-11-10
- "Fails open" no longer holds. Since WP-70a a failed Valkey call is decided by an
in-memory limiter in the same process (
FallbackRateLimiterincrates/commonlayer-auth/src/adapters/rate_limit.rs): limits stay in force, but per process, so N API replicas allow up to N times a limit during an outage. Every fallback counts incommonlayer_rate_limiter_fallbacks_total{bucket},/readyzreportsrate_limiterasdegraded, and the alertRateLimiterDegradedfires. Failing closed was rejected because it would turn a cache outage into a login outage. - The client address used for per-IP buckets is taken from
X-Forwarded-Foronly when the direct peer is a declared trusted proxy (ADR-0040).