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.
Protocol errors
Section titled “Protocol errors”/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"}| Error | Status | What it usually means |
|---|---|---|
invalid_request | 400 | A required parameter is missing or malformed. |
invalid_client | 401 | Client authentication failed — wrong secret, bad assertion, unknown client. |
invalid_grant | 400 | The code, refresh token, or subject token is unknown, expired, or already used. |
unauthorized_client | 400 | The client exists but is not allowed this grant type. |
unsupported_grant_type | 400 | grant_type is not one of the four supported grants. |
invalid_scope | 400 | No requested scope is grantable to this client. |
invalid_target | 400 | A resource indicator is malformed, or not among those authorized. |
invalid_dpop_proof | 400 | The DPoP proof is missing, stale, replayed, or does not match the request. |
invalid_token | 401 | The access token is expired, revoked, or audience-mismatched. |
insufficient_scope | 403 | The token is valid but lacks the scope this endpoint requires. |
invalid_client_metadata | 400 | Dynamic registration was sent metadata the platform will not accept. |
invalid_redirect_uri | 400 | A 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"Management API errors
Section titled “Management API errors”/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" }| Error | Status | Meaning |
|---|---|---|
unauthorized | 401 | Missing, unknown, or revoked API key. |
insufficient_scope | 403 | The key lacks the permission named in required. |
forbidden | 403 | The key is valid but scoped to a different org. |
not_found | 404 | No 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_scope | 400 | A requested permission is not a known permission name. |
invalid_url | 400 | A webhook or stream URL failed the egress policy check. |
rate_limited | 429 | Over 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.
Rate limits
Section titled “Rate limits”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.
| Bucket | Endpoints | Limit | Key |
|---|---|---|---|
token, authorize, par | /token, /authorize, /par | 120 / min | IP |
register | /register | 20 / min | IP |
introspect, revoke, userinfo | /introspect, /revoke, /userinfo | 240 / min | IP (userinfo: principal) |
credentials, mcp | /credentials/*, /credential, /mcp/tools/* | 120 / min | principal |
mgmt | /v1/* | 600 / min | principal |
scim | /scim/v2/* | 600 / min | principal |
webauthn, mfa, sessions, connect | passkey, TOTP, session, and account-linking endpoints | 60 / min | IP |
saml, sso, consent | /saml/acs, /sso/*, /consent | 60 / min | IP |
login-email | /login/email/*, /login/link/*, and the console’s email verify | 10 / min | IP |
login-mfa | /login/mfa/* | 10 / min | IP |
login-password | /login/password/* | 30 / min | IP |
login-social, console-login, logout | social sign-in, console login, logout | 20 / min | IP |
portal | /admin/portal/* | 30 / min | IP |
health | /health | 30 / min | IP |
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.
Reading the headers
Section titled “Reading the headers”Every rate-limited response carries its budget, whether or not it was throttled:
RateLimit-Limit: 600RateLimit-Remaining: 517RateLimit-Reset: 34RateLimit-Reset is seconds until the window rolls. On a 429 you also get Retry-After:
HTTP/1.1 429 Too Many RequestsRetry-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.