Skip to content

Errors and rate limits

There are two error shapes, because there are two surfaces. Protocol endpoints follow the OAuth error contract; the management API uses its own.

/token, /authorize, /par, /register, /introspect, /revoke, and the credential endpoints return OAuth-style errors (RFC 6749 §5.2), with the HTTP status matching the class of failure:

{
"error": "invalid_grant",
"error_description": "code invalid, used, or expired"
}
ErrorStatusWhat it usually means
invalid_request400A required parameter is missing or malformed.
invalid_client401Client authentication failed — wrong secret, bad assertion, unknown client.
invalid_grant400The code, refresh token, or subject token is unknown, expired, or already used.
unauthorized_client400The client exists but is not allowed this grant type.
unsupported_grant_type400grant_type is not one of the four supported grants.
invalid_scope400No requested scope is grantable to this client.
invalid_target400A resource indicator is malformed, or not among those authorized.
invalid_dpop_proof400The DPoP proof is missing, stale, replayed, or does not match the request.
invalid_token401The access token is expired, revoked, or audience-mismatched.
insufficient_scope403The token is valid but lacks the scope this endpoint requires.
invalid_client_metadata400Dynamic registration was sent metadata the platform will not accept.
invalid_redirect_uri400A registered redirect URI is not https, loopback, or a safe custom scheme.

Two of these deserve special attention.

invalid_grant on a refresh token may not mean “expired”. Replaying a refresh token that has already been rotated is treated as theft: the whole rotation family is revoked and a refresh_reuse_detected audit event is written. If users are being signed out unexpectedly, look for that event before assuming a TTL problem — see token lifecycle.

invalid_token with audience mismatch means the token is genuine but was minted for a different resource. That is the resource indicator doing its job.

Endpoints that require a token also return a WWW-Authenticate challenge pointing at the protected-resource metadata (RFC 9728), which in turn names the authorization server — so a client that receives a 401 with no prior configuration can discover where to get a valid token:

WWW-Authenticate: Bearer resource_metadata="https://acme.oauth.work/.well-known/oauth-protected-resource",
error="insufficient_scope", scope="tool:search"

/v1 endpoints return an error code and, where it helps, a detail or required field:

{ "error": "insufficient_scope", "required": "credentials:issue" }
{ "error": "invalid_provider", "detail": "kind must be github | google | slack | custom" }
ErrorStatusMeaning
unauthorized401Missing, unknown, or revoked API key.
insufficient_scope403The key lacks the permission named in required.
forbidden403The key is valid but scoped to a different org.
not_found404No such resource in this org. Also user_not_found, member_not_found, provider_not_found, account_not_found, log_stream_not_found, webhook_not_found.
invalid_scope400A requested permission is not a known permission name.
invalid_url400A webhook or stream URL failed the egress policy check.
rate_limited429Over the window limit for this bucket.

Note the difference between insufficient_scope and forbidden: the first is about what the key may do, the second about whose data it may touch. An org-scoped key holding credentials:issue still gets forbidden when it addresses another org.

Where a resource is addressed by id inside an org path, an id belonging to a different org returns 404 rather than 403 — a user id from another tenant comes back user_not_found. The id is not yours to know about, so the response does not confirm it exists.

Limits are fixed-window, counted in a Durable Object, and applied per bucket. Most buckets key on the client IP; the ones marked principal key on the authenticated caller when there is one, and fall back to IP otherwise — so one noisy API key cannot exhaust another’s budget.

BucketEndpointsLimitKey
token, authorize, par/token, /authorize, /par120 / minIP
register/register20 / minIP
introspect, revoke, userinfo/introspect, /revoke, /userinfo240 / minIP (userinfo: principal)
credentials, mcp/credentials/*, /credential, /mcp/tools/*120 / minprincipal
mgmt/v1/*600 / minprincipal
scim/scim/v2/*600 / minprincipal
webauthn, mfa, sessions, connectpasskey, TOTP, session, and account-linking endpoints60 / minIP
saml, sso, consent/saml/acs, /sso/*, /consent60 / minIP
login-email/login/email/*, /login/link/*, and the console’s email verify10 / minIP
login-mfa/login/mfa/*10 / minIP
login-password/login/password/*30 / minIP
login-social, console-login, logoutsocial sign-in, console login, logout20 / minIP
portal/admin/portal/*30 / minIP
health/health30 / minIP

The login buckets are tightest on purpose. login-email also caps outbound email, so one-time codes, magic links, and the console’s email verify all draw on one shared budget — burning it on codes throttles links too. login-mfa is a separate bucket at the same limit, so a user who has already passed a first factor is not competing with that budget to finish the second. login-password is deliberately roomier than login-email so that the per-email lockout — ten failures against one address — engages first and surfaces as invalid_credentials, rather than the whole IP hitting a 429 that tells an attacker nothing about which address they got wrong.

Every rate-limited response carries its budget, whether or not it was throttled:

RateLimit-Limit: 600
RateLimit-Remaining: 517
RateLimit-Reset: 34

RateLimit-Reset is seconds until the window rolls. On a 429 you also get Retry-After:

HTTP/1.1 429 Too Many Requests
Retry-After: 34
{ "error": "rate_limited", "error_description": "too many requests" }

Back off for Retry-After seconds rather than retrying immediately — the window is fixed, so a tight retry loop simply burns the next window too. For bulk work against /v1, prefer fewer larger requests and use Idempotency-Key so a retry after a timeout cannot create a duplicate.