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:
- Read the
error_description— most providers include specific details - Is the authorization code fresh? — Exchange immediately, never retry with the same code
- Does
redirect_urimatch exactly? — Check trailing slashes, protocol, port - Is the PKCE
code_verifiercorrect? — Verify the stored value matches the challenge - Are client credentials correct? — Verify
client_idandclient_secretfor the right environment - Is the refresh token still valid? — Check idle timeout, absolute lifetime, rotation
- Has the user’s password changed? — Password resets invalidate tokens on most providers
- Is the server clock in sync? — Run
ntpdate -q pool.ntp.org - Check IdP logs — Keycloak events, Auth0 logs, Azure AD sign-in logs
- 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:
| Provider | Default Lifetime |
|---|---|
| Keycloak | 2 minutes |
| Auth0 | ~60 seconds |
| Okta | 5 minutes |
| ~10 minutes | |
| Azure AD | ~10 minutes |
| ForgeRock AM | Configurable 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:
/callbackvs/callback/ - Protocol:
http://vshttps:// - Port:
localhost:3000vslocalhost: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_verifierper request instead of reusing from authorization step - Losing the stored verifier on page redirect (use
sessionStorage, not memory) - Using
plainmethod whenS256was specified
Refresh Token Issues
Expired refresh token — Refresh tokens have finite lifetimes:
| Provider | Idle Timeout | Absolute Lifetime |
|---|---|---|
| Keycloak | 30 min (SSO Session Idle) | 10 hours (SSO Session Max) |
| Auth0 | 14 days | Configurable (up to ~2.6 years) |
| Okta | 7 days | Unlimited (configurable) |
| 6 months inactivity | No absolute limit | |
| Azure AD | 90 days inactivity | Configurable |
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 mismatch — AADSTS700005: the authorization code was issued for a different tenant than the token request targets.
Azure AD SPA token lifetime — AADSTS700084: SPA refresh tokens have fixed, non-extendable lifetimes that cannot be renewed.
Keycloak cache eviction — Session 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_description | Cause |
|---|---|
Code not valid | Expired code, replayed code, redirect_uri mismatch, PKCE failure |
Session not active | User session expired |
Token is not active | Refresh token expired or revoked |
Session doesn't have required client | Cache eviction |
Invalid user credentials | Wrong 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_description | Cause |
|---|---|
Invalid authorization code | Expired or replayed code |
Unknown or invalid refresh token. | Expired, revoked, or rotated token |
Failed to verify code verifier | PKCE 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_description | Cause |
|---|---|
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 Code | Meaning |
|---|---|
AADSTS50126 | Invalid username or password |
AADSTS50173 | Grant expired due to password change/reset |
AADSTS54005 | Authorization code already redeemed |
AADSTS70008 | Refresh token expired due to inactivity |
AADSTS700005 | Code used against wrong tenant |
AADSTS700082 | Refresh token inactive too long |
AADSTS501481 | PKCE 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 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.
Related Articles
- Understanding the Authorization Code Flow with PKCE in OAuth 2.0
- How OAuth 2.1 Refresh Tokens Work: Best Practices and Expiry
- How to Revoke OAuth 2.0 Tokens and Secure Your Applications
- OAuth 2.1 Security Best Practices: Mandatory PKCE and Token Binding
- How to Introspect OAuth 2.0 Tokens
- PKCE Generator Tool
