AUTH4D-333 Protocol and provider rate limits

The OAuth protocol routes and the sign-in provider routes spend from coordinated budgets in the tenant’s rate-limit Durable Object (@auth4/storage-do). Every bucket is keyed by the tenant and by the caller’s IP, or by a client and IP pair. The subject is HMAC-pseudonymized with the deployment privacy key before it reaches the Durable Object. The limits are in apps/api/src/modules/oauth/limits.ts (PROTOCOL_BUDGETS).

Limits

All windows are fixed one-minute windows. Each route has its own bucket. A full hosted sign-in (authorize, provider start, provider callback, continue) spends one unit from four different budgets.

Route Budget
GET /oauth/authorize 30 per IP (authorizationStartsPerIp)
POST /oauth/authorize/continue 60 per IP (authRequestsPerIp)
POST /oauth/logout 60 per IP (authRequestsPerIp)
POST /oauth/token (code and refresh grants) 600 per IP, plus 30 per public client and IP
POST /oauth/revoke 600 per IP, plus 30 per public client and IP
/oauth/token, /oauth/revoke for a server client 600 per IP, plus 600 per server client and IP
Client secret checks on /oauth/token, revoke, introspect 1,200 per IP
GET /t/{tenantId}/identity/{steam,discord}/{start,callback} 60 per IP and route (authRequestsPerIp)
/t/{tenantId}/identity/{google,apple,microsoft,epic}/… 60 per IP and route; a form-post callback shares its bucket
/t/{tenantId}/sso/start, OIDC callback and SAML ACS 60 per IP and route; the SAML ACS POST and GET share a bucket

Notes:

  • tokenRequestsPerClient (30 per minute) is applied per client and IP. Every player of a game shares one public client id, so a cap on the client alone would lock out all players once 30 of them refreshed in the same minute. A server client calls for all of its players from a few addresses, so it gets 600 per minute, the same as its introspection budget (AUTH4D-155 (repository file docs/api-spec/auth4d-155.md)).
  • The per-IP token and revoke budget (600 per minute) leaves room for many players behind one NAT address: with five-minute access tokens, one refresh per player every five minutes allows about 3,000 players per address.
  • The client secret budget is spent before the secret is compared, once the named client is known to exist. Once it is gone the endpoint answers 429 without comparing the secret, so a right and a wrong secret get the same answer and no guess is evaluated. Because the unit is spent first, successful checks count too; 1,200 per minute is above one server client’s 600 per minute introspection budget plus its token calls.
  • Token and revoke budgets are spent after the client named by the request is loaded, so a request naming an unknown client gets 401 invalid_client without creating rate-limit state. Provider route budgets are spent only for an active customer tenant, for the same reason.
  • Authorize spends after the tenant and client are known; continue spends before the one-time flow is taken, so a limited request does not burn the player’s sign-in.
  • The device grant at /oauth/token keeps its own budgets in the device module and does not spend from these buckets.
  • A staging smoke run makes four authorize and seven token calls, far below every limit.

Responses

A spent budget answers 429 with the shape the introspection budget already uses:

HTTP/1.1 429 Too Many Requests
Retry-After: 37
Cache-Control: no-store, max-age=0
Content-Type: application/json; charset=utf-8

{"error":"slow_down"}

Retry-After is the number of seconds until the bucket resets. A browser navigation (a GET with Sec-Fetch-Mode: navigate or Accept: text/html) to /oauth/authorize or a provider route gets the hosted-login “temporarily unavailable, try again in a moment” screen instead, still with status 429 and Retry-After. Without a trusted client IP (no CF-Connecting-IP outside the local environment) these routes fail closed with 503 temporarily_unavailable.

Server clients at /oauth/authorize

/oauth/authorize accepts only browser applications (clientType: "browser") with the authorization_code grant, as the hosted guest path already does. A native or server client gets the usual error redirect to its registered redirect URI:

HTTP/1.1 302 Found
Location: https://server.example.test/callback?error=unauthorized_client&state=…

No flow is opened and no cookie is set. Native games sign in with device authorization (AUTH4D-16 (repository file docs/api-spec/auth4d-16.md)). GET /t/{tenantId}/login/context answers 404 invalid_request for a server client, the same as for an unknown application; native clients keep their guest-linking and device-approval context.

Source: docs/api-spec/auth4d-333.md