OAuth · intermediate
OAuth invalid_grant: identify the expired or mismatched authorization state
Diagnose and resolve OAuth `invalid_grant` errors by distinguishing expired authorization codes, replay attempts, redirect URI mismatches, client credential drift, and PKCE/state mismatches. The guide provides an evidence-first triage sequence for engineers reading authorization server responses, server logs, and browser artifacts to pinpoint which precondition failed before applying a targeted fix and proving it with a controlled replay.
The symptoms
- •Authorization server returns HTTP 400 or 401 with `error=invalid_grant` (or `error=invalid_grant` plus `error_description`) during the token endpoint POST, while the authorization request itself succeeded.
- •Identity provider logs show a successful `/authorize` redirect containing a `code` and `state`, but the subsequent token exchange is rejected within seconds to minutes of issuance.
- •Application logs record an OAuthException or equivalent with message text referencing "grant", "authorization code", "code expired", "code_verifier", "redirect URI", or "client" alongside the `error_description` field.
- •End users report being bounced back to the login screen or seeing "Authentication failed" after what appears to be a successful SSO redirect from the IdP.
- •Token endpoint requests fail intermittently but succeed for the same user minutes later, suggesting time-bound or single-use state rather than a persistent configuration fault.
- •Browser developer tools show the `code` query parameter present on the redirect URI, but the application's server-side handler responds with a token-refresh or re-authentication flow instead of establishing a session.
Likely causes
- •Authorization code has exceeded the authorization server's short lifetime (commonly 30–600 seconds) before the client exchanged it at the token endpoint.
- •Authorization code was already consumed once (single-use enforcement) and is being replayed by a retried redirect, a load balancer retry, or a double-submit from the client.
- •Redirect URI sent to the token endpoint does not byte-match the URI registered for the client, including scheme, host, port, path, and query parameters that the IdP echoes back.
- •Client authentication at the token endpoint is failing (wrong `client_id`, rotated `client_secret`, missing or malformed `client_secret_post`/`client_secret_basic` credentials, or JWT assertion clock skew for `private_key_jwt`).
- •PKCE `code_verifier` does not hash to the `code_challenge` sent during authorization, or `code_challenge_method` differs between steps (e.g., S256 on authorize, plain on token).
- •Antivirus, corporate proxy, or browser privacy settings stripped or modified a session/state cookie used to bind the callback to the originating authorization request, severing state context.
First ten minutes
- 01Capture the exact authorization server response: status code, full `error`, `error_description`, `error_uri`, and `state` parameters from the token endpoint POST; treat `invalid_grant` as a class, not a verdict.
- 02Read the authorization server's log entry for the corresponding `code` value to determine which precondition it enforced: expiration, single-use, redirect URI match, client auth, PKCE, or session binding.
- 03Verify the redirect URI on the token request byte-matches the value registered with the client and the value echoed in the `/authorize` redirect's `redirect_uri` parameter.
- 04Measure elapsed time between the `/authorize` redirect containing `code=` and the token endpoint POST; compare against the documented code lifetime (often 60 seconds).
- 05Inspect the application's redirect handler for retries, idempotency keys, or duplicate form submissions that could replay the same `code` twice.
- 06Check whether the browser request includes the session/state cookie expected by the callback handler; absence usually indicates cookie stripping rather than an OAuth server fault.
Evidence to collect
- •Authorization server's token endpoint response body containing `error`, `error_description`, `error_uri`, and `state` fields; preserve headers like `Cache-Control` and `Pragma` to confirm no caching layer is replaying.
- •Authorization server access log entry tying the failing token request to its issuing `code`, including client identifier, redirect URI used, timestamp, and the precondition that triggered rejection.
- •Browser-side artifacts: the full redirect URL including query string with `code` and `state`, `Set-Cookie` headers from the `/authorize` response, and any cookies sent on the callback request.
- •Client application's server logs for the callback handler showing whether the `state` parameter matched the value stored before redirect and whether the code exchange ran once or multiple times.
- •Client configuration values: registered redirect URIs, `client_id`, `client_secret` or assertion, PKCE parameters (`code_challenge`, `code_challenge_method`, `code_verifier`), and any token cache state.
- •Network proxy or load balancer logs between client and IdP for retransmissions, TLS terminations, or header rewrites that could alter the redirect URI or strip cookies.
Where to look
- •Identity provider's token endpoint logs and OAuth audit trail, scoped to the failing `client_id` and the `code` value returned by `/authorize`.
- •The client's OAuth callback handler: the route that receives the redirect, parses `code` and `state`, and triggers the token exchange; examine for retry logic and idempotency handling.
- •The boundary between the user's browser and the application server, where cookies set during `/authorize` are expected to survive the redirect; this is where Set-Cookie semantics govern session binding.
- •The application's outgoing HTTPS client used to POST to the token endpoint, where redirect URI string construction, client credential encoding, and PKCE verifier transmission are finalized.
- •Any reverse proxy, API gateway, or CDN in front of the application that may rewrite redirect URIs, normalize paths, or retry POSTs on idempotency-key absence.
- •Configuration store (secrets manager, environment file, OAuth library config) where client credentials, redirect URI list, and authorization server endpoints are declared.
Diagnostic steps
- 01Branch on the `error_description` field: "code expired" or lifetime-related text points to timing; "redirect URI" text points to URI mismatch; "client" or "authentication" text points to client credentials; "PKCE" or "verifier" text points to code challenge mismatch.
- 02Compare the redirect URI sent on the token request against the registered value character-by-character: scheme, host, port, path, and trailing slash; even a query parameter the IdP echoes back can cause rejection.
- 03Reconcile the `code_challenge_method` declared during `/authorize` with the verifier transformation applied at the token endpoint; a method switch between authorize and token phases causes hash mismatch.
- 04Reproduce the failure with a fresh authorization request and a single, manual token exchange to isolate whether the cause is environmental (proxy, retry) or deterministic (config, code).
- 05Disable any client-side retry or load balancer idempotency for the token endpoint POST, then re-run the flow once; success after disabling confirms double-submit as the cause.
- 06Validate Set-Cookie attributes (`Secure`, `HttpOnly`, `SameSite`, `Path`, `Domain`) returned by the IdP during `/authorize`; mismatches with browser policy explain cookie loss on the callback leg.
Common mistakes
- •Treating every `invalid_grant` as "code expired" and lengthening lifetimes, when the actual cause is a redirect URI mismatch or PKCE verifier error that will persist regardless of TTL.
- •Comparing redirect URIs with case-insensitive string equality and missing that the IdP enforces exact byte match including path case and trailing slash.
- •Logging the `code` value in application logs and then triggering an automatic retry that consumes it, so the legitimate manual exchange is the one that fails with `invalid_grant`.
- •Reusing the same `state` value across requests or storing `state` in a cookie that is stripped by browser privacy settings, causing state mismatch even though authorization succeeded.
- •Rotating the `client_secret` in the IdP without updating every client instance, so the first request with the new secret succeeds and subsequent retries with cached old secrets fail authentication.
- •Assuming the token endpoint POST is idempotent and letting a reverse proxy retry it on timeout, producing a successful first exchange followed by a guaranteed `invalid_grant` on the retry.
Safe fixes
- •If `error_description` indicates expiration, shorten the gap between `/authorize` redirect and token exchange by deferring non-essential work, and confirm the documented code lifetime with the IdP before changing client behavior.
- •If redirect URI mismatch is confirmed, update the registered value to byte-match the URI the client sends, including any query parameters the IdP echoes back, and redeploy the client configuration.
- •If single-use is the cause, add idempotency on the callback handler so the same `code` is exchanged at most once per incoming request, and prevent reverse proxies from retrying the token POST without an `Idempotency-Key`.
- •If PKCE mismatch is confirmed, regenerate `code_verifier` and `code_challenge` with the documented `code_challenge_method`, persist the verifier across the redirect, and send it once on the token request.
- •If client authentication fails, rotate `client_secret` in the secrets store, restart client instances to flush cached credentials, and verify the token endpoint accepts the configured client auth method.
- •If cookies are stripped, align `Set-Cookie` attributes from `/authorize` with browser policy (`Secure`, `SameSite`) and ensure the callback is same-site so the state-binding cookie survives the redirect.
Prove the fix
- 01Run the full authorization flow in a controlled environment and observe a single 200 OK response from the token endpoint with an access token body and no `error` field; record the `code`-to-token elapsed time.
- 02Replay the same `code` value a second time against the token endpoint and observe a deterministic `invalid_grant` with `error_description` indicating single-use, confirming replay protection is intact.
- 03Trigger the callback handler twice with the same incoming request (e.g., duplicate the POST) and verify only one token exchange occurs, proving the idempotency fix prevents double-submit.
- 04Inspect the `Set-Cookie` header from the IdP's `/authorize` response and confirm the same cookie is sent by the browser on the callback request; absence on callback indicates cookie-binding regression.
- 05Capture a token endpoint response after intentionally using a mismatched redirect URI and confirm the IdP still rejects with `invalid_grant`, proving the rejection path remains active and the fix is scoped, not a blanket suppression.
- 06After deploying the fix, observe zero `invalid_grant` responses in the IdP audit log for the affected `client_id` over a full business-day window of representative user traffic.
Prevention and next steps
- •Treat authorization codes as single-use and short-lived at the client: exchange them immediately on the callback, persist the resulting access and refresh tokens keyed by user, and never retry the token POST on transport failure.
- •Centralize OAuth client configuration in a versioned store and validate redirect URIs with byte-exact comparison at startup so configuration drift fails fast rather than at user login time.
- •Wire IdP audit logs into alerting with thresholds on `invalid_grant` rate per `client_id` so spikes point to a specific precondition before users report outages.
- •Document and test cookie-binding for the OAuth callback so privacy-mode browsers, corporate proxies, and cross-site redirect scenarios are validated as part of the release checklist.
- •Generate PKCE verifiers with a cryptographically secure source per request, persist them with the in-flight `state`, and discard them after a single use to prevent replay across sessions.
Safe commands and checks
openssl rand -base64 64 | tr -d '=+/' | cut -c1-<verifier-length> # Generate a high-entropy PKCE code_verifier of the documented length; do not reuse across requests.
printf '%s' '<code_verifier>' | openssl dgst -sha256 -binary | openssl base64 | tr '+/' '-_' | tr -d '=' # Derive the S256 code_challenge from the verifier for comparison with the value sent during /authorize.
printf '%s' '<code_verifier>' | openssl dgst -sha256 # Confirm the SHA-256 hash of the verifier locally; mismatches with the IdP's expected challenge indicate transmission or encoding faults.
grep -nE 'invalid_grant|error_description|code_verifier|redirect_uri' <application-log-path> # Search application logs for the rejection class, the description field, and the two most diagnostic parameter names.
awk -F'[?&]' '/\\/authorize\\?/{print $0}' <proxy-log-path> # Extract the full /authorize redirect URLs from a proxy or access log so the redirect_uri echo can be diffed against the registered value.
awk '/\\/oauth\\/token/ && /POST/ {print}' <proxy-log-path> | head -n <count> # List recent token endpoint POSTs without exposing bodies, useful for spotting duplicate submissions from retries.
openssl x509 -in <certificate-path> -noout -subject -issuer -dates # Validate the TLS certificate served by the IdP endpoint when client authentication failures mention TLS or JWT assertion issues.
date -u +%Y-%m-%dT%H:%M:%SZ # Capture the current UTC timestamp for correlating the /authorize redirect time with the token endpoint rejection time during expiration analysis.