Architecture Decision Record
ADR-019: Layered Rate Limiting with an Authenticated-Only Global Limiter
Status
Accepted. Revised 2026-08-01 (the auth-ip per-IP anonymous-authentication limiter, which the shared
auth controller applies to login and register by default, is recorded as the third layer; the
anonymous-surface trade-off was corrected to match; the edge trust posture behind every IP partition
key was made explicit). Revised 2026-08-18 (the hard-coded limits become a bound RateLimitingSettings
section, a sliding-window algorithm option joins the fixed window, and the global and UserPolicy
partitions gain an optional Redis-backed distributed limiter with a fail-open posture, which partly
retires the "in-process counters" trade-off below; auth-ip stays deliberately local. See the
Revision (2026-08-18) at the end).
Context
Every service exposes read and write endpoints to the public internet through the gateway (ADR-008). Abusive or runaway clients (scrapers, credential stuffing, retry storms, a buggy SPA stuck in a loop) can exhaust a service's threads, database connections, and downstream quotas. ASP.NET Core ships a rate-limiting middleware, but "turn on rate limiting" is not the decision: the load-bearing questions are who gets limited, by what partition key, and what is exempt. A naive per-IP global limiter is actively wrong for this deployment for three reasons:
- Public read endpoints are output-cached, so legitimate anonymous browsing should be cheap, not throttled.
- Anonymous server-rendered (Blazor Server) traffic all shares the UI host's outbound IP, so an IP partition would throttle every public visitor as if they were a single abuser.
- Login and registration brute-force is a distinct threat with a distinct control (account lockout and per-IP registration throttling), not a general request cap.
Decision
Rate limiting is layered, and the always-on global limiter is authenticated-only.
- A global limiter that only caps authenticated callers.
AddCommonRateLimiting(MMCA.Common.API) installs aGlobalLimiter(active on every request throughUseRateLimiter) that:- Exempts infrastructure traffic outright (
NoLimiter):/health,/alive, JWKS / OIDC discovery (/.well-known/*), and gRPC inter-service calls (application/grpccontent type). These are legitimately high-frequency. - Exempts anonymous traffic (
NoLimiter): unauthenticated requests are not counted, for the three reasons above. - Caps each authenticated caller to
globalPermitLimit(default 300) requests per fixed one-minute window, partitioned by identity name, then theuser_idclaim, then remote IP, rejecting overage with429 Too Many Requests.
- Exempts infrastructure traffic outright (
- Anonymous abuse is handled by the right-shaped control, not the global limiter. Public reads
are served from the output cache (
UseOutputCache; ADC's Conference service definesEventsCache/CategoriesCache/QuestionsCache/RoomsCachepolicies on its public controllers), and login/registration brute-force is handled byLoginProtectionService(exponential-backoff account lockout afterMaxFailedAttemptsfailed logins, plus per-IP registration throttling). - A per-IP cap on the anonymous authentication endpoints, on by default.
AddCommonRateLimitingalso registers the named policyRateLimitPolicyAuthIp("auth-ip"): a fixed one-minute window keyed on the client IP,authIpPermitLimit(default 30) requests, overage rejected with429. Unlike the other named policies it is not left to each app to attach.AuthControllerBase(MMCA.Common.API) decoratesLoginAsyncandRegisterAsyncwith[EnableRateLimiting(WebApplicationBuilderExtensions.RateLimitPolicyAuthIp)], so any consumer that inherits the base gets it without opting in. What an override inherits, settled empirically (2026-08-13):EnableRateLimitingAttributeleavesAttributeUsage.Inheritedat its default oftrue, and a derived override therefore still sees the base attribute throughGetCustomAttributes(inherit: true), so a bare override very likely retains the policy rather than silently dropping it. The convention is unchanged regardless: apply the attribute explicitly on every override (ADC always did; Store now does), because endpoint metadata resolution is not something an app should have to reason about per action, and a dropped policy fails silently. Two guards pin it instead of leaving it to argument: a MMCA.Common reflection test that asserts both the base decoration and the inherited-on-an-override case, and a Store integration test that reads the booted host'sEndpointDataSourceand assertsPOST /Auth/registercarries theauth-ippolicy in its metadata (the only check independent of the reflection question). It exists because the other two layers leave one hole between them: the global limiter no-ops for anonymous traffic and the lockout is keyed per email, so a password spray (one password, many emails) from a single source was otherwise unthrottled. Three details are deliberate:RefreshAsyncis not throttled (renewal is automatic and periodic, and Blazor Server circuits issue it server-side from the UI host's IP); a request with no attributable IP getsNoLimiterrather than sharing one bucket with every other such request, mirroring the global limiter's fail-open posture; and the default is 30 rather than a tighter 10 for the same shared-IP reason, since every Server-circuit user's login leaves from the UI host's address. - The remaining named policies are opt-in, per-endpoint tightening.
AddCommonRateLimitingalso registersFixedPolicyandUserPolicy, which a specific action can apply with[EnableRateLimiting(...)]when it needs a tighter cap than the global default. Nothing in either app applies those two today. - The client IP these partitions key on is whatever the edge forwards, and that is trusted from any
proxy. Both IP-keyed decisions above (the
auth-ipwindow, and the global limiter's third-choice fallback key) readHttpContext.Connection.RemoteIpAddress, whichUseForwardedHeadershas already rewritten fromX-Forwarded-Forearlier in the same shared pipeline (UseCommonMiddlewarePipelineruns forwarded headers beforeUseRateLimiter). That middleware is configured to trust any proxy: the pipeline clears bothKnownProxiesandKnownIPNetworks, because cloud reverse proxies (Azure Container Apps, AWS ALB) front the services from internal addresses that are not in the default allow-lists, and an unlisted proxy means the headers are ignored and every request is attributed to the proxy instead. The decision is to take a caller-supplied IP as canonical rather than to run with no usable client IP at all.
Rationale
- Limit the traffic that is both attributable and expensive. An authenticated request is tied to a principal and usually drives the database; capping per-principal stops a single account from monopolizing a service without punishing the public read path.
- Do not punish shared-origin anonymous traffic. With Blazor Server fronting public browsing behind one IP, and public reads served from the output cache, an anonymous IP cap would throttle legitimate visitors at scale while barely protecting an already-cached backend.
- Right control per threat. Brute-force is an auth concern answered by a per-email lockout plus a per-IP cap on the two endpoints that carry it; general overload is a per-user request cap; infrastructure endpoints must never be throttled. A single global IP bucket conflates all three.
Trade-offs
- The global limiter only protects the authenticated surface. The anonymous surface is covered
endpoint by endpoint instead: login and register carry the
auth-iplimiter by default and the login-protection service on top of it, and public reads are served from the output cache. An uncached anonymous endpoint added later inherits none of that: it has no global cap and must opt into a named policy or its own control. - The per-IP cap keys on a header a caller can set. Trusting forwarded headers from any proxy is
what makes the IP usable at all behind a cloud load balancer, but it makes a spoofable input the
partition key of a security control. The exposure is bounded rather than absent:
ForwardLimitis left at its default of 1, so only the rightmostX-Forwarded-Forentry is consumed, and a proxy that appends the real client address wins for any traffic that actually traverses it. A caller that can reach a service without passing through that proxy can name its own IP and mint a freshauth-ipbucket per request, leaving the per-email lockout (ADR-029, not IP-keyed) as the control still standing in front of a spray. Containing this is a deployment concern, not a limiter one: either the gateway is the only reachable ingress, or the allow-lists carry the real proxy ranges. Neither is enforced in code today. - Per-user partitioning depends on the authenticated principal being populated when the limiter partitions the request, so the limiter's placement relative to authentication in the request pipeline is load-bearing: move it and the partition sees a different (or empty) principal.
- Defaults are deployment-agnostic. 300 requests/min/user is a coarse backstop, not a tuned SLO; a service with heavier legitimate per-user traffic must raise it, and a stricter endpoint must opt into a named policy.
- In-process counters. Limiter state is per-instance, so across N replicas the effective ceiling is roughly N times the configured limit. This is an accepted backstop, not a distributed quota.
Revision (2026-08-18)
The layering above is unchanged: the global limiter is still authenticated-only, infrastructure and
anonymous traffic are still exempt, auth-ip still covers login and register by default, and
FixedPolicy / UserPolicy are still opt-in with nothing applying them. Three things below it
changed.
- The limits are now a validated settings section.
RateLimitingSettings(MMCA.Common/Source/Presentation/MMCA.Common.API/RateLimiting/RateLimitingSettings.cs:21) binds the"RateLimiting"section (:24) atMMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:307, falling back to a default instance when the section is absent, so an unconfigured host keeps exactly the behavior this record describes. Every figure the Decision quotes now has a named home and a[Range]:GlobalPermitLimit300 (:38-40),AuthIpPermitLimit30 (:46-47),PerUserPermitLimit30 forUserPolicy(:34-36), andPermitLimit100 plusQueueLimit2 forFixedPolicy(:26-32). - A sliding window is selectable.
Algorithm(:53) takesRateLimitAlgorithm.FixedWindow(the default) orSlidingWindow(.../RateLimiting/RateLimitAlgorithm.cs:15,22), withSegmentsPerWindowdefaulting to 4 (:61-62,[Range(1, 60)], ignored underFixedWindow). The window itself stays one minute under both algorithms (RateLimitAlgorithm.cs:5-6), so this is a smoothing choice, not a new cap: a fixed window lets a caller spend a full minute's budget in the last second of one window and again in the first second of the next, and the segmented window removes that doubling at the cost of holding per-segment state. - The global and
UserPolicypartitions can be Redis-backed.Distributed(:72, defaultfalse) swaps inRedisFixedWindowRateLimiter(.../RateLimiting/RedisFixedWindowRateLimiter.cs:37), which keys onrl:{partitionKey}:{unixMinute}(:129-130), performs aStringIncrementAsync(:135), and sets a 65-second TTL only on the increment that created the key (:137-143, the 5 seconds of slack being deliberate clock skew,:139-141), admitting the request when the returned count is within the permit limit (:145). Exactly two partitions opt in: the global limiter (WebApplicationBuilderExtensions.cs:79-104, Redis scope"global"at:99) andUserPolicy(:114-128, scope"user"at:123).
auth-ip deliberately stays in-memory (allowDistributed: false,
WebApplicationBuilderExtensions.cs:234, rationale at :143-147), as does FixedPolicy
(:346-353). The per-IP cap on the anonymous authentication endpoints is a coarse brute-force
backstop sitting in front of a control that is already global and stateful, the ADR-029 per-email
lockout; making it distributed would put a Redis round trip on the login path to tighten a limit whose
per-replica multiplication is already accounted for in its generous default of 30.
The distributed limiter fails open. Any Redis fault other than cancellation is caught and the
lease is granted (:147-155), with a warning emitted at most once per window through an
Interlocked.Exchange guard on a static field (:149-151, :44). That is the same posture the
global limiter already takes for a request with no attributable IP: a rate limiter is a backstop, and
a broken backstop must not become an outage. Two consequences are worth naming. The increment and the
comparison are not transactional (:26-29), so genuinely concurrent requests can overshoot the limit
slightly, which is accepted for a coarse cap. And setting Distributed = true in a host with no
IConnectionMultiplexer registered silently degrades to the in-memory limiter rather than failing
startup (:150-167, documented at RateLimitingSettings.cs:64-71), so this setting sits outside the
ADR-070 fail-fast contract and a misconfiguration looks exactly like success.
The "In-process counters" trade-off above is therefore narrowed rather than removed: the effective
ceiling is still roughly N times the configured limit across N replicas for auth-ip and
FixedPolicy, and for the global and per-user partitions in any host that has not set Distributed
or has no multiplexer. Every other trade-off in this record stands unchanged, including the
forwarded-header trust posture, which the Redis partition key inherits verbatim.
Related
ADR-004 (the JWKS/discovery traffic the limiter exempts, and the authenticated principal it keys on),
ADR-008 (the gateway edge this protects), ADR-017 (request idempotency, the other inbound-edge
safeguard against client retries), ADR-029 (the per-email lockout and registration throttle that sit
on the same two endpoints as the auth-ip cap, and the reason auth-ip stays local), ADR-026 (the
Redis the distributed limiter reuses, and the IncrementAsync storage-format lesson the raw INCR
here avoids by owning its own rl: keyspace), ADR-070 (the fail-fast configuration contract
RateLimitingSettings binds into, and the Distributed degradation that sits outside it), ADR-079
(the shared middleware pipeline that places UseRateLimiter after authentication and after forwarded
headers, which is what makes both partition keys resolvable).