Skip to main content

Errors

Errors follow the RFC 6749 shape — an error code and, usually, an error_description:

{"error": "invalid_grant", "error_description": "授权码已过期"}

Two things to know before you write any handling code:

  • error_description is Chinese, and it is not a stable interface. Branch on error and on the HTTP status. Use the description for your own logs only, and never show it to an end user.
  • Descriptions are not unique per cause. Several distinct failures share one error code. Where that matters, it is called out below.

Authorization request​

GET /oauth/authorize returns these as JSON in the browser. They are not redirected to your callback, so a user hitting one sees a raw JSON page — which means these are bugs in your integration to be fixed before launch, not runtime conditions to handle.

Statuserrorerror_descriptionCause
400invalid_request缺少 client_id 或 redirect_uri 参数client_id or redirect_uri missing or empty
400unsupported_response_type仅支持 response_type=coderesponse_type present and not code
400invalid_client应用不存在或已被禁用Unknown client_id, or the application is disabled
400invalid_redirect_uri回调地址未注册redirect_uri is not byte-identical to a registered URI
400invalid_scopescope 'X' 不被允许X is outside your permitted set. An empty X means your scope string had a double, leading or trailing space.

Checks run in that order, so you see the first failure only. Fix and retry.

Errors delivered to your callback​

Once the user is on the consent screen, failures come back as query parameters on your redirect URI, with state re-appended.

errorMeaningWhat to do
access_deniedThe user declinedReturn them to your app; do not retry automatically
login_requiredprompt=none and the user is not signed inRetry without prompt=none
consent_requiredprompt=none and no matching grant existsRetry without prompt=none
interaction_requiredprompt=none and the grant check failedRetry without prompt=none

Token endpoint​

POST /oauth/token.

Statuserrorerror_descriptionCause
400unsupported_grant_type—grant_type is absent or not one of the two supported values. Also what you get when you send a JSON body — the fields are never read.
401invalid_client—Unknown client_id, or a wrong client_secret. The two are not distinguished.
400invalid_grant授权码无效或已使用The code is unknown, belongs to a different client, or was already redeemed
400invalid_grant授权码已过期More than 10 minutes since the code was issued
400invalid_grantredirect_uri 不匹配The redirect_uri differs from the one sent to /oauth/authorize
400invalid_grant需要 code_verifierThe code was issued with PKCE but no code_verifier was sent
400invalid_grantcode_verifier 验证失败The verifier does not match the challenge — often a code_challenge_method typo
400invalid_grant刷新令牌无效Unknown refresh token, or one issued to a different client
400invalid_grant刷新令牌已被使用,所有令牌已撤销An already-rotated refresh token was presented — see below
400invalid_grant刷新令牌已过期Past the 30-day window that started at first authorization

invalid_client when you are sure the secret is right​

Two causes account for nearly every case:

  1. The = was not percent-encoded. Secrets and tokens end in =. If you assembled the body as a string instead of letting your HTTP library encode a parameter map, the value was truncated.
  2. You sent HTTP Basic. Credentials go in the form body. client_secret_basic is not supported and produces no distinguishing error.

刷新令牌已被使用,所有令牌已撤销​

This is reuse detection. Presenting a refresh token that has already been rotated is treated as a replay, and the response revokes every access and refresh token for that user and application.

You reach it in three ways:

  • You stored the refresh token from a response but the process died before the write landed, so you replayed the old one.
  • Two workers refreshed concurrently. Serialize refreshes per user.
  • The user revoked your application from their Nanako account settings, which marks the tokens revoked; your next honest refresh then looks like a replay.

In all three cases the recovery is the same: discard the stored tokens and send the user through the authorization flow again. Do not retry.

UserInfo​

GET /oauth/userinfo.

Statuserrorerror_descriptionCause
401invalid_token—No Authorization header, or it does not start with Bearer
401invalid_token—Unknown or revoked token
401invalid_tokentoken 已过期Past the 2-hour lifetime
500server_error—The account behind the token no longer exists

On the two undescribed 401s, refresh and retry once. If the refresh also fails, re-authorize.

Revocation​

POST /oauth/revoke.

StatuserrorCause
400invalid_requestclient_id or token missing or empty — including when the body is JSON
401invalid_clientUnknown client_id or a wrong client_secret
200—Everything else, including a token that does not exist, is not yours, or was sent without the required token_type_hint

200 does not mean something was revoked. See POST /oauth/revoke.

Not from OAuth​

Two responses can reach you from the surrounding infrastructure rather than from the OAuth handlers:

StatusBodyMeaning
503{"error":"feature_disabled","feature":"developer"}An operator has switched off the developer subsystem. It carries Retry-After: 300. Application management and the account UI's authorized-apps list are affected; the sign-in flow itself is not.
413—Request body over 110 MB. You should never see this on an OAuth endpoint.

A retry policy that works​

ResponseRetry?
401 invalid_token on userinfoYes — refresh once, then retry
invalid_grant on refreshNo — re-authorize
invalid_grant on the code exchangeNo — the code is spent; restart from /oauth/authorize
invalid_clientNo — this is a configuration error
503 feature_disabledYes — after Retry-After
5xxYes — with backoff, and only for idempotent reads

Never retry a code exchange with the same code. Codes are single-use, and the second attempt fails whether or not the first succeeded.