Skip to main content

Solutions for common OAuth2-related problems

Spec-compliant OAuth 2.0 and OpenID Connect is hard. Let's take a look how to resolve certain issues.

OpenID Connect ID Token missing

If you expect an OAuth 2.0 ID Token but aren't receiving one, this can have multiple reasons:

  1. You are using the client_credentials grant which can't return an ID token.
  2. You forgot to request the openid scope when calling /oauth2/auth?response_type=code (Authorize Code Flow - correct would be /oauth2/auth?response_type=code&scope=openid) or the id_token response type when calling /oauth2/auth?response_type=token (Implicit/Hybrid flow - correct would be /oauth2/auth?response_type=token+id_token&scope=openid or any other combination such as response_type=id_token, response_type=token+code+id_token).
  3. You forgot to include nonce when calling /oauth2/auth?response_type=id_token or any combination of id_token (token+id_token, token+code+id_token etc.) nonce is a value sent by the client application and included as a claim in the returned id_token. The nonce claim can then be verified by the client application to mitigate any replay attacks. Optional for Authoize Code Flow, but mandatory for Implicit/Hybrid flow. Correct request would be - /oauth2/auth?response_type=id_token&nonce=some-value. You can read more about nonce here OpenId Connect Core.
  4. Your consent app didn't send granted_scope: ["openid"] or when accepting the consent request.
  5. The OAuth 2.0 Client making the request isn't allowed to request the scope openid.

OAuth 2.0 Refresh Token is missing

If you expect an OAuth 2.0 Refresh Token but aren't receiving one, this can have multiple reasons:

  1. You are using an implicit or hybrid flow. These flows never return a refresh token!
  2. You are using the client_credentials grant which can't return a refresh token.
  3. You forgot to request the offline or offline_access scope when calling /oauth2/auth.
  4. You consent app didn't send granted_scope: ["offline"] or granted_scope: ["offline_access"] when accepting the consent request.
  5. The OAuth 2.0 Client making the request isn't allowed to grant type refresh_token.

OAuth 2.0 authorize code flow fails

The most likely cause is misconfiguration of the OAuth 2.0 client or the redirect URL.

Wrong or misconfigured OAuth 2.0 client

You are using the wrong OAuth 2.0 Client or the OAuth 2.0 Client has a broken configuration. To check that you're using the right client, run:

ory get oauth2-client {client.id}}

The result shows you the whole client (excluding its secret). Check that the values are correct. Example:

{
"client_id": "my-client",
"grant_types": [
"authorization_code"
],
"jwks": {},
"redirect_uris": [
"http://127.0.0.1:5556/callback"
],
"response_types": [
"code"
],
"scope": "openid offline",
"subject_type": "pairwise",
"token_endpoint_auth_method": "client_secret_basic",
"userinfo_signed_response_alg": "none"
}

Redirect URL isn't whitelisted

A likely cause of your request failing is that you are using the wrong redirect URL. Assuming your OAuth 2.0 URL looks like https://$PROJECT_SLUG.projects.oryapis.com/oauth2/auth?client_id=my-client&...&redirect_uri=http://my-url/callback

The redirect URL http://my-url/callback must be whitelisted in your client configuration. The URLs must match. URL http://my-url/callback and http://my-url/callback?foo=bar are different URLs!

To see the whitelisted redirect_uris, check the client:

ory get oauth2-client {client.id}

{
// ...
"redirect_uris": [
"http://127.0.0.1:5556/callback"
],
// ...
}

Here you see that http://my-url/callback isn't in the list, which is why the request fails.

Refresh Token flow fails

Refresh tokens in Ory OAuth2 and Ory Hydra are single-use. When a client redeems a refresh token at /oauth2/token, the server returns a new access token and a new refresh token, and invalidates the old refresh token.

If an already-used refresh token is presented a second time, Ory treats that as a leaked token and revokes the entire token chain for that consent, logging out both the legitimate client and any attacker. This effectively prevents abuse from refresh tokens leaked during refresh. However, it also means that a faulty client that accidentally reuses a refresh token can cause the same result.

A common case of a defective client implementation is a mobile app that tries to refresh tokens in the background, but gets suspended by the operating system before the response arrives. When it retries with the prior token on its next wake-up, it replays a spent token and triggers reuse detection.

Another defective implementation occurs in the browser, where separate tabs or share a refresh token by storing it in cookie or local storage, and they all try to refresh at the same time when the access token expires. The first request rotates the token; the rest replay a spent token.

The failures below all stem from a client redeeming a refresh token that the server has already rotated.

Other possible causes include:

  • The client doesn't store the new refresh token. Each response contains a new refresh_token. If the client keeps using the original instead of replacing its stored copy, the next refresh replays a spent token.
  • Unsynchronized shared storage. Instances read the refresh token from a shared store but write the rotated value back without locking, so they overwrite each other and replay stale tokens.

To fix the client, make sure each refresh token is redeemed once and the rotated token is stored before it is used again:

  • Let the request finish even if the app is suspended. On iOS, run the refresh on a background URLSession, which completes out-of-process and is delivered when the app is woken, rather than a standard session that is killed on suspension. If a background session isn't an option, wrap the call in beginBackgroundTask(withName:expirationHandler:). Other platforms have equivalent background-completion mechanisms.
  • Store the rotated refresh token before using it. On every response, persist the new refresh_token and access token before using the new access token, and discard the previous refresh token.
  • Serialize refreshes per session. Use a lock so only one refresh runs at a time for a session; callers that arrive during an in-flight refresh wait for and reuse its result.
  • Don't blindly retry an inconclusive refresh. If a refresh fails without a definitive response, re-read the current token from the shared store and let the serialized refresh path decide whether another refresh is needed, rather than retrying with the token you originally sent.

If none of these mitigations solve your problem, consider sending the user through a new OAuth2 authorization code flow to obtain a new refresh token if you inadvertently revoked the previous one. If you instructed Hydra to remember the user's consent during their prior login+consent journey, their next journey through the login+consent flow can be zero-click. This should be your preferred solution if the refresh token is invalidated only occasionally.

If your client implementation genuinely cannot be fixed, graceful refresh token rotation lets a refresh token be redeemed more than once within a short grace period. This weakens the single-use guarantee for the duration of the grace period and typically increases latency on /oauth2/token due to contention on the token chain, so use it as a migration aid rather than a replacement for correct client behavior.

/oauth2/token endpoint fails for JWKS based client

When trying to get an access token for a client registered with "token_endpoint_auth_method": "private_key_jwt" it's possible that the provided jwt has expired.

The response body that's sent to the client is like below

{
"error": "error",
"error_description": "The error is unrecognizable: Token is expired"
}

The debug error messages isn't provided to avoid divulging more information to a malicious client.

If you need more information in response for debugging, switch the project to development mode. Now you will be able to see the error_debug field in the response like below:

{
"error": "error",
"error_description": "The error is unrecognizable",
"status_code": 500,
"error_debug": "Token is expired"
}