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

  1. 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.
  2. 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.
  3. 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.
  4. 04Measure elapsed time between the `/authorize` redirect containing `code=` and the token endpoint POST; compare against the documented code lifetime (often 60 seconds).
  5. 05Inspect the application's redirect handler for retries, idempotency keys, or duplicate form submissions that could replay the same `code` twice.
  6. 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

  1. 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.
  2. 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.
  3. 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.
  4. 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).
  5. 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.
  6. 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.