The invalid_grant error is the most common and most confusing OAuth error. It appears during token exchange or refresh token requests, but the same error code covers 18+ different root causes. This guide catalogs every known cause with provider-specific error messages and exact debugging commands.

Quick Diagnostic Checklist

When you encounter invalid_grant, work through this list in order:

  1. Read the error_description — most providers include specific details
  2. Is the authorization code fresh? — Exchange immediately, never retry with the same code
  3. Does redirect_uri match exactly? — Check trailing slashes, protocol, port
  4. Is the PKCE code_verifier correct? — Verify the stored value matches the challenge
  5. Are client credentials correct? — Verify client_id and client_secret for the right environment
  6. Is the refresh token still valid? — Check idle timeout, absolute lifetime, rotation
  7. Has the user’s password changed? — Password resets invalidate tokens on most providers
  8. Is the server clock in sync? — Run ntpdate -q pool.ntp.org
  9. Check IdP logs — Keycloak events, Auth0 logs, Azure AD sign-in logs
  10. Is Google app in “Testing” mode? — Tokens expire after exactly 7 days

All Causes of invalid_grant

Authorization Code Issues

Expired code — Authorization codes have short lifetimes:

ProviderDefault Lifetime
Keycloak2 minutes
Auth0~60 seconds
Okta5 minutes
Google~10 minutes
Azure AD~10 minutes
ForgeRock AMConfigurable per OAuth2 Provider

Code already used — Authorization codes are single-use. If the same code is exchanged twice (even accidentally from network retries), all subsequent attempts fail. Per RFC 6749, the server SHOULD revoke tokens already issued from that code.

Redirect URI mismatch — The redirect_uri in the token request must exactly match the authorization request. Watch for:

  • Trailing slash: /callback vs /callback/
  • Protocol: http:// vs https://
  • Port: localhost:3000 vs localhost:8080
  • URL encoding differences

PKCE Failures

code_verifier mismatch — The verifier sent at token exchange must produce the same challenge sent during authorization. Verify with:

# Generate code_challenge from a code_verifier
echo -n "YOUR_CODE_VERIFIER" | openssl dgst -sha256 -binary | \
  openssl base64 | tr '+/' '-_' | tr -d '='
# Output must match the code_challenge from the authorization request

Common mistakes:

  • Generating a new code_verifier per request instead of reusing from authorization step
  • Losing the stored verifier on page redirect (use sessionStorage, not memory)
  • Using plain method when S256 was specified

Refresh Token Issues

Expired refresh token — Refresh tokens have finite lifetimes:

ProviderIdle TimeoutAbsolute Lifetime
Keycloak30 min (SSO Session Idle)10 hours (SSO Session Max)
Auth014 daysConfigurable (up to ~2.6 years)
Okta7 daysUnlimited (configurable)
Google6 months inactivityNo absolute limit
Azure AD90 days inactivityConfigurable

Revoked refresh token — Triggers include: user password reset, admin revoking sessions, user removing app access, and security policy changes.

Rotation replay — When refresh token rotation is enabled, using an already-rotated (old) token invalidates the entire token family. Both Auth0 and Okta implement this behavior.

Session and Credential Issues

User session expired — The user’s session on the AS expired between authorization and token exchange.

Wrong client credentials — Incorrect client_id or client_secret. Some providers return invalid_client instead; behavior varies.

Clock skew — Token timestamps (iat, exp, nbf) fail validation when server clocks diverge. Check with:

# macOS
sntp time.apple.com

# Linux
timedatectl status
ntpdate -q pool.ntp.org

Provider-Specific Causes

Google Testing mode — Apps with OAuth consent screen set to “Testing” have refresh tokens that expire after exactly 7 days. Publish to “Production” to fix.

Google 100-token limit — Google allows a maximum of 100 live refresh tokens per client per user. Oldest tokens are silently invalidated when the limit is exceeded.

Azure AD tenant mismatchAADSTS700005: the authorization code was issued for a different tenant than the token request targets.

Azure AD SPA token lifetimeAADSTS700084: SPA refresh tokens have fixed, non-extendable lifetimes that cannot be renewed.

Keycloak cache evictionSession doesn't have required client: client sessions evicted from the Infinispan cache. Increase cache sizes or configure lazy loading for offline sessions.

Provider Error Message Reference

Keycloak

error_descriptionCause
Code not validExpired code, replayed code, redirect_uri mismatch, PKCE failure
Session not activeUser session expired
Token is not activeRefresh token expired or revoked
Session doesn't have required clientCache eviction
Invalid user credentialsWrong username/password (ROPC)

Enable debug logging:

bin/kc.sh start --log-level="INFO,org.keycloak.protocol.oidc:TRACE"

Check events via Admin REST API:

curl -H "Authorization: Bearer $ADMIN_TOKEN" \
  "https://keycloak.example.com/admin/realms/your-realm/events?type=CODE_TO_TOKEN_ERROR&max=10"

Auth0

error_descriptionCause
Invalid authorization codeExpired or replayed code
Unknown or invalid refresh token.Expired, revoked, or rotated token
Failed to verify code verifierPKCE mismatch
Wrong email or password.Invalid credentials (ROPC)

Check logs: Dashboard > Logs > filter by type feccft (Failed Exchange: Authorization Code for Access Token).

Okta

error_descriptionCause
The authorization code is invalid or has expired.Expired or replayed code
The refresh token is invalid or expired.Expired or revoked token
PKCE verification failed.code_verifier mismatch
The credentials provided were invalid.Wrong credentials

Azure AD / Entra ID

AADSTS CodeMeaning
AADSTS50126Invalid username or password
AADSTS50173Grant expired due to password change/reset
AADSTS54005Authorization code already redeemed
AADSTS70008Refresh token expired due to inactivity
AADSTS700005Code used against wrong tenant
AADSTS700082Refresh token inactive too long
AADSTS501481PKCE code_verifier mismatch

Lookup any code: https://login.microsoftonline.com/error?code=XXXXX

ForgeRock AM / PingOne AIC

Debug with transaction ID header:

curl -v -X POST https://am.example.com/oauth2/access_token \
  -H "X-ForgeRock-TransactionId: debug-$(date +%s)" \
  -d "grant_type=authorization_code&code=CODE&redirect_uri=URI&client_id=ID"

Then grep the transaction ID in debug logs: $AM_HOME/var/debug/OAuth2Provider

Google

Google returns a generic message for all causes:

{"error": "invalid_grant", "error_description": "Token has been expired or revoked."}

Check: Testing mode (7-day expiry), password reset, 6-month inactivity, 100-token limit, manual revocation.

Common Developer Mistakes

Mistake 1: Exchanging the Code Twice

React useEffect in StrictMode, network retry middleware, or browser prefetch can fire the token exchange twice. The first succeeds; the second fails with invalid_grant.

let exchangeInProgress = false;

async function exchangeCode(code) {
  if (exchangeInProgress) return; // Prevent duplicate exchange
  exchangeInProgress = true;
  try {
    const response = await fetch('/oauth/token', {
      method: 'POST',
      body: new URLSearchParams({
        grant_type: 'authorization_code',
        code,
        redirect_uri: REDIRECT_URI,
        client_id: CLIENT_ID,
        code_verifier: sessionStorage.getItem('pkce_code_verifier')
      })
    });
    return await response.json();
  } finally {
    exchangeInProgress = false;
  }
}

Mistake 2: Losing the PKCE code_verifier

The code_verifier must survive the redirect from the authorization server. Store it in sessionStorage (survives redirects within the same tab), NOT in memory.

// Before redirect to authorization endpoint
sessionStorage.setItem('pkce_code_verifier', codeVerifier);

// After redirect back (callback)
const codeVerifier = sessionStorage.getItem('pkce_code_verifier');
sessionStorage.removeItem('pkce_code_verifier');

Mistake 3: Not Handling Refresh Token Rotation

When rotation is enabled, each refresh returns a new refresh token. If you fail to store it, subsequent refreshes use the old (invalidated) token.

const tokens = await refreshAccessToken(oldRefreshToken);
// CRITICAL: Store the NEW refresh token
if (tokens.refresh_token) {
  secureStorage.setItem('refresh_token', tokens.refresh_token);
}

Mistake 4: Concurrent Refresh Requests

Multiple threads refreshing simultaneously: the first succeeds and rotates the token, subsequent requests fail. Use a mutex:

let refreshPromise = null;

async function getValidAccessToken() {
  const token = getStoredAccessToken();
  if (token && !isExpired(token)) return token;

  if (!refreshPromise) {
    refreshPromise = refreshAccessToken(getStoredRefreshToken())
      .finally(() => { refreshPromise = null; });
  }
  const tokens = await refreshPromise;
  return tokens?.access_token;
}

Debugging with curl

Reproduce the exact token exchange to isolate the issue:

curl -v -X POST https://your-idp.com/oauth2/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "code=AUTHORIZATION_CODE" \
  -d "redirect_uri=https://your-app.com/callback" \
  -d "client_id=YOUR_CLIENT_ID" \
  -d "client_secret=YOUR_CLIENT_SECRET" \
  -d "code_verifier=YOUR_CODE_VERIFIER" \
  2>&1 | tee token_response.log

The -v flag reveals request headers, exact body sent, and full server response including error details some providers add in response headers.