Fix invalid_grant at the token endpoint
The user signs in, comes back to the app with a code, and then the app's request to the token endpoint fails with 400 Bad Request and {"error":"invalid_grant"}. The error is deliberately vague. The OAuth 2.0 specification lets the server return it when the grant is "invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client" - and for PKCE, when the verifier does not match.
The error_description often says more. Entra ID adds a code: AADSTS54005 (code already redeemed), AADSTS70008 (code or refresh token expired), AADSTS700082 (refresh token expired from inactivity).
Authorization codes: causes in order of likelihood
1. The code was redeemed twice
A code can be used once. The first token request succeeds; a second one with the same code gets invalid_grant, and some providers also revoke the tokens the first request got. Common reasons:
- React
StrictModeruns effects twice in development, so a callback component that exchanges the code inuseEffectsends two token requests. - The callback page is reloaded, or the user presses Back to it.
- Both a library and your own code handle the callback.
Look for two token requests with the same code in a few milliseconds. Guard the exchange so it runs once per code, and remove the code from the URL straight after.
2. The PKCE verifier doesn't match the challenge
With PKCE the app sends code_challenge on the authorize request and the matching code_verifier on the token request. For S256 the rule is code_challenge = BASE64URL(SHA-256(code_verifier)), with no padding. It breaks when:
- the verifier is regenerated between the two requests (stored in memory and the page reloaded, or in
sessionStorageof another tab or origin); - the challenge is computed with standard base64 (
+,/,=) instead of base64url; - the hash is hex-encoded before base64, or taken over something other than the verifier's ASCII text;
- the verifier is shorter than 43 or longer than 128 characters, or uses characters outside
A-Z a-z 0-9 - . _ ~.
Check a pair yourself:
printf %s "$CODE_VERIFIER" | openssl dgst -sha256 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=' # must print exactly the code_challenge from the authorize request
3. redirect_uri differs between the two requests
If the authorize request carried a redirect_uri, the token request must send the identical string. A trailing slash, http vs https behind a proxy, or an added query string is enough. (If the authorize request itself was refused for its redirect URI, see AADSTS50011 and redirect_uri mismatch.)
4. The code expired
Codes are short-lived - often well under ten minutes. A breakpoint in the callback handler while debugging is a classic way to run out the clock. So is a server whose clock is far off.
5. Wrong client or wrong tenant
The token request uses a different client_id from the authorize request, or goes to a different tenant or authorization-server endpoint (for example an Okta custom authorization server vs the org server, or /common vs a tenant ID in Entra ID).
Refresh tokens
For grant_type=refresh_token, invalid_grant means the refresh token is no longer usable: expired, revoked by a password change or admin action, or - with refresh-token rotation - already used once. Rotation is the subtle one: if two tabs or two requests refresh at the same time, the second uses a token that was just replaced, and many providers then revoke the whole chain. Serialise refreshes, then send the user through sign-in again when this happens.
How AuthTrace shows it
Open the AuthTrace panel in DevTools before signing in. It links the authorize request, the callback and the token request in one timeline and checks them against each other: PKCE verifier does not match the challenge (with the expected value), redirect_uri differs between authorize and token, and the provider's error and vendor code explained in plain words. Duplicate token requests sit next to each other in the timeline.
More guides
Fix a SAML audience mismatch
What "AudienceRestriction not satisfied" means, how to find the two values that disagree, and where to change them in the IdP.
SAMLDecode a SAMLResponse
Base64, DEFLATE and the two bindings - how to read a SAML message by hand, and which fields matter when sign-in breaks.
SAMLFix an InResponseTo mismatch
The app says the response doesn't match its request, or calls it unsolicited. Usually a lost session cookie - here is how to prove it.