What this usually means
Authorization code flow errors typically stem from a mismatch between what the client application sends and what the authorization server expects. The most common root cause is a redirect URI mismatch – the URI registered in the OAuth provider's console must exactly match the URI used in the authorization request (including protocol, port, and trailing slash). Next is the state parameter: it's a CSRF token that must be validated on the callback; if missing or incorrect, the flow aborts. Then there are token exchange issues: the authorization code is single-use and short-lived (often 5-10 minutes), so if the token request is delayed or the code is reused, you get 'invalid_grant'. PKCE introduces additional failure points: the code challenge must match the verifier, and the method (S256 vs plain) must be supported by the provider. Provider-specific quirks (e.g., Google's 'authSubToken' vs 'access_token', Azure AD's 'v1' vs 'v2' endpoints) add further confusion.
The first ten minutes — establish facts before touching code.
- 1Open browser DevTools on the callback URL. Check the query string for 'code' and 'state' parameters. If 'code' is missing, the authorization request was rejected.
- 2Inspect the authorization request URL: ensure 'redirect_uri' matches exactly the registered callback URL (case, port, trailing slash).
- 3Verify the state token: generate a new one and confirm that it matches on callback. Look for 'state_mismatch' log entries.
- 4Attempt to exchange the code manually using curl or Postman. Use the exact same parameters as the app. Check the response body and HTTP status.
- 5Check the token endpoint URL: confirm it's correct for the provider and tenant (e.g., Azure AD uses different endpoints for v1 vs v2).
- 6If using PKCE, verify the code challenge method: S256 is standard, but some providers require SHA-256 base64url encoding without padding.
The specific files, logs, configs, and dashboards that usually own this bug.
- searchOAuth provider console → registered redirect URIs (e.g., Google Cloud Console → APIs & Services → Credentials → OAuth 2.0 Client IDs)
- searchApplication logs: search for 'state', 'code', 'error', 'invalid_grant', 'redirect_uri_mismatch'
- searchNetwork tab in DevTools: capture the full authorization request URL and the callback request with query parameters
- searchToken endpoint response body: often contains error_description with the exact cause (e.g., 'Authorization code has expired')
- searchAuth library configuration (e.g., Passport.js strategy options, Spring Security client config, etc.)
- searchPKCE-related: check 'code_challenge' and 'code_challenge_method' in the authorization request; 'code_verifier' in the token request
Practical causes, not theory. These are the things you will actually find.
- warningRedirect URI in the authorization request does not exactly match the registered URI (including http vs https, www vs no-www, trailing slash).
- warningState parameter not generated or validated: missing, expired, or stored incorrectly in session.
- warningAuthorization code expired or used more than once (code is single-use).
- warningClient secret is wrong or rotated without updating the application.
- warningToken endpoint URL is incorrect (e.g., wrong tenant ID, wrong version).
- warningPKCE code challenge does not match verifier (encoding issues, wrong method).
- warningScope mismatch: requested scopes not granted or not available.
Concrete fix directions. Pick the one that matches your root cause.
- buildNormalize redirect URIs: always use the exact string registered in the provider console. Avoid trailing slashes unless registered that way.
- buildImplement robust state validation: generate a cryptographically random state, store it in the session, and compare on callback. Use a short TTL.
- buildHandle token expiration: ensure the token exchange happens immediately after receiving the code. Add retry logic with backoff.
- buildUse a well-tested OAuth library (e.g., `openid-client` for Node.js, `oauth2-client` for Spring) to avoid manual implementation bugs.
- buildFor PKCE, always use S256 and base64url-encode the challenge without padding. Verify the encoding with a known test vector.
- buildCentralize OAuth configuration: use environment variables for client ID, secret, endpoints, and redirect URI. Validate at startup.
A fix you cannot prove is a guess. Close the loop.
- verifiedAfter fixing the redirect URI, initiate the flow and confirm the callback URL contains the 'code' parameter with a 302 redirect.
- verifiedUse an OAuth debugger tool (e.g., OAuth 2.0 Playground) to step through the flow and verify each parameter.
- verifiedWrite an integration test that performs the full flow against a test OAuth server (e.g., using Nock or WireMock).
- verifiedCheck the provider's logs (if available) for the specific error reason (e.g., Google Cloud's OAuth consent screen logs).
- verifiedMonitor application logs for 'state' validation success and token exchange HTTP 200 response.
- verifiedRun a security scan to ensure state is validated and PKCE is enforced.
Things that make this bug worse or harder to find.
- warningHardcoding redirect URIs or client secrets in source code; use environment variables or secret managers.
- warningAssuming all OAuth providers behave identically; always read the specific provider's documentation.
- warningStoring the state parameter in local storage or cookies without encryption; it's a CSRF token.
- warningIgnoring the 'error_description' in the token response; it often contains the exact fix.
- warningReusing the same authorization code for multiple token requests; always generate a new code per flow.
- warningForgetting to handle the case where the user denies consent; the callback may have 'error=access_denied' instead of 'code'.
The Phantom invalid_grant: A Tale of a Rotated Secret
Timeline
- 09:15User reports being unable to log in via Google; gets 'invalid_grant' on callback.
- 09:20I check logs: token exchange returns HTTP 400 with error 'invalid_grant'.
- 09:25Manual curl of token endpoint with same 'code' works? No, also fails with 'invalid_grant'.
- 09:30Check Google Cloud Console: client ID and secret are correct. Redirect URI matches exactly.
- 09:35I notice the 'code' in the callback URL appears to be the same as a previous one? No, it's new.
- 09:40I regenerate a fresh authorization URL and try again. Still fails.
- 09:45I check the token endpoint URL: it's 'https://oauth2.googleapis.com/token'. That's correct.
- 09:50I inspect the network request: the 'client_secret' parameter is being sent as an environment variable. I echo it in logs: it's the old secret!
- 09:52Rotate the secret in the environment variable to the current one. Redeploy.
- 09:55Test again: token exchange succeeds. User can log in.
At 09:15, a support ticket came in: 'Cannot log in with Google. Error: invalid_grant'. I've seen this before – usually a code that expired or was reused. But the user insisted they just started the flow. I pulled up the logs: the token exchange was returning HTTP 400 with 'invalid_grant'. The authorization code itself looked fresh (timestamp in the code payload). I tried to replicate: started a new OAuth flow, got a code, exchanged it via curl – same error. So the code wasn't the issue.
I checked the Google Cloud Console: the redirect URI matched exactly, client ID was correct. But when I looked at the client secret, I noticed it had been rotated two days ago. In our environment variables, we had the old secret still set. The secret was updated in the console but not in the application. That's why the token endpoint rejected the request: the client authentication failed silently, and Google returns 'invalid_grant' instead of 'invalid_client' in some cases.
I updated the environment variable with the new secret, restarted the service, and tested again. The flow worked. The lesson: always sync client secrets across all environments when rotating, and never rely on 'invalid_grant' being only about the code. Also, add a startup check that validates the client secret by hitting the token endpoint with a dummy request (or at least log a warning if the secret is older than a certain date).
Root cause
Client secret rotated in Google Cloud Console but not updated in the application's environment variables, causing token exchange to fail with 'invalid_grant'.
The fix
Update the environment variable GOOGLE_CLIENT_SECRET with the new secret from the console. Optionally add a health check that verifies the secret periodically.
The lesson
OAuth2 'invalid_grant' can be misleading; always verify client credentials first. Implement secrets rotation alerts and automatic detection of mismatched secrets.
The redirect URI is the single most common cause of OAuth2 authorization code flow failures. The authorization server compares the redirect_uri parameter in the authorization request against the registered URIs exactly character by character. A trailing slash, a different case (e.g., /Callback vs /callback), or using HTTP instead of HTTPS will cause a mismatch. Many providers return a generic 'redirect_uri_mismatch' error, but some (like Azure AD) may silently fail and not even redirect back.
To debug: capture the exact authorization request URL from the browser's network tab. Compare the redirect_uri parameter with the registered URI in the provider console. Pay attention to port numbers (localhost:3000 vs localhost:3000/ are different). For dynamic redirect URIs (e.g., per-tenant), ensure the registration includes a wildcard or the exact URI. Tools like OAuth 2.0 Playground can help validate.
The state parameter is a CSRF token that must be generated per request and validated on the callback. If the state is missing, expired, or doesn't match, the flow aborts. Common implementation mistakes: storing state in a cookie without encryption (vulnerable to CSRF), using a static state, or failing to clear the state after validation (leading to replay attacks).
Debug by logging the state value sent in the authorization request and the state received on the callback. If they differ, check session persistence: the state must be retrievable at callback time. For server-side sessions, ensure the session ID is consistent. For SPAs, use the authorization code flow with PKCE and avoid state altogether if possible (PKCE provides CSRF protection).
The token exchange step converts the authorization code into tokens. The code is single-use and short-lived (typically 5-10 minutes). If the exchange fails with 'invalid_grant', the code may have expired or been used already. But as in the incident, it can also be a client authentication issue. Always check the error_description in the response body.
For PKCE, the code_verifier must match the code_challenge sent in the authorization request. The challenge is computed as base64url-encoded SHA-256 hash of the verifier. Common mistakes: using non-base64url characters, including padding, or using 'plain' method when the provider expects S256. Verify by computing the challenge independently and comparing. Some providers (e.g., Okta) have strict requirements on verifier length (43-128 characters).
Each OAuth provider has its own nuances. Google requires the redirect URI to be exactly as registered (no trailing slash). Azure AD has two versions of endpoints (v1: /common/oauth2/authorize, v2: /common/oauth2/v2.0/authorize) with different token formats. Facebook expects a different parameter order. GitHub returns the code in a query parameter but also sometimes in the fragment.
Always refer to the provider's official documentation for the correct endpoints, parameter names, and supported grant types. Use the provider's debug tools (e.g., Google's OAuth 2.0 Playground, Azure's portal logs) to see exactly what parameters are being sent and received. Also note that some providers require a client secret for public clients (like mobile apps) even though the spec says it's optional.
To catch OAuth2 flow errors in production, log the full redirect URI, state, and error codes at every step. Use structured logging (JSON) with fields like `oauth_step`, `error_code`, `error_description`. Monitor for spikes in 'invalid_grant' or 'redirect_uri_mismatch' errors. Set up alerts for token exchange failures.
Consider adding a health check endpoint that performs a mock authorization flow against the provider (using a test client) to validate configuration. Also, implement automatic secret rotation detection by comparing the hash of the secret with a known value. Finally, use correlation IDs to trace a single OAuth flow across multiple services.
Frequently asked questions
Why does my OAuth2 callback get no 'code' parameter?
If the callback URL does not contain a 'code' parameter, the authorization server likely rejected the request. The most common reasons: redirect URI mismatch, invalid scope, or the user denied consent. Check the URL fragment or query for an 'error' parameter (e.g., 'error=access_denied'). Also ensure the callback URL is exactly as registered (including trailing slash).
What does 'invalid_grant' mean and how do I fix it?
'invalid_grant' means the authorization code or refresh token is invalid, expired, or was already used. First, ensure you're using the code within its lifetime (usually 5-10 minutes). Second, never reuse a code. If that's not the issue, the error can also be caused by a mismatched redirect URI during token exchange (some providers require the same redirect URI as in the authorization request) or incorrect client credentials. Check the error_description in the response for more details.
Should I use PKCE even for server-side apps?
Yes. PKCE (Proof Key for Code Exchange) is recommended for all OAuth2 authorization code flows, even for confidential clients. It provides an additional layer of security against authorization code interception attacks. Many providers (like Google) now require PKCE for public clients, and it's a best practice for all. It adds minimal complexity: just generate a code_verifier and code_challenge on the client side.
How do I debug a 'redirect_uri_mismatch' error from Google?
Google's error page for redirect_uri_mismatch shows the exact redirect URI it received. Copy that URI and compare it to the one registered in the Google Cloud Console. Pay attention to trailing slashes, protocol (http vs https), and port number. Also ensure that the redirect URI is not being modified by URL encoding (e.g., spaces encoded as %20). Use the OAuth 2.0 Playground to test with the exact same URI.
Why does my token exchange fail with 'invalid_client'?
'invalid_client' means the client authentication failed. Check that the client ID and client secret are correct. If you're using a client secret, ensure it hasn't been rotated. Some providers also require the client authentication to be sent in the Authorization header (Basic auth) rather than the request body. Verify the token endpoint documentation for the exact authentication method. Also, for confidential clients, ensure the client type is set correctly in the provider console.