Diagnosing invalid_grant on OAuth Refresh in Public Clients
invalid_grant from a public OAuth client usually traces to token state, a lost PKCE verifier, or config mismatch. A diagnostic order for separating the two grants and fixing each finding.
05 Dec 2025, 16:27 UTC

Your app uses Authorization Code with PKCE. Login works. A day later, API calls return 401 and the token endpoint answers invalid_grant. The useful takeaway: invalid_grant comes from two different grants, and in public clients most occurrences trace to local token state, a lost PKCE verifier, or a configuration mismatch rather than a broken authorization server. Work the checks below in order before opening a ticket.
Separate the two grants that return invalid_grant
RFC 6749 defines invalid_grant at the token endpoint generally. In practice you see it from the authorization_code exchange and from the refresh_token grant, and they have different causes. If the failure happens on the first exchange after the browser redirect, PKCE verifier handling is in scope. If login succeeded and a refresh token is stored, you are in refresh territory, and code_verifier is not the parameter to inspect.
Keep invalid_client separate as well. That error points at client authentication: a client_id mismatch, or a public client sending a client_secret it should not have. The fix is different.
Cause table
| Symptom pattern | Likely cause | First check |
|---|---|---|
| First refresh fails, then every retry fails | Rotation reuse detection: an old refresh token was replayed | Whether two processes or devices share one stored token |
| Fails only after app restart or backgrounding | Refresh token not persisted, or stored in ephemeral storage | Where the token is written and when |
| Fails on the first exchange after redirect | PKCE code_verifier lost when the app was killed | Whether the verifier is persisted before the browser opens |
invalid_client instead of invalid_grant | client_id mismatch or unexpected secret | Client registration and request body |
| Fails after a device time change | Clock skew against server-issued expiry claims | Local time versus the token endpoint's Date header |
| Affects specific users only | Server-side revocation or expiry for those accounts | Authorization server audit events |
Ordered checks
- Identify the failing grant. Inspect the request body your app sends:
grant_type=authorization_codeorgrant_type=refresh_token. Run this from app logs or a local proxy on a test build, and redact tokens before sharing logs. - Confirm you hold the current refresh token. With rotation enabled, the server may invalidate the previous token once a new one is issued. If two processes read the same keychain or keystore entry, one will replay an old value. Check that storage has a single writer and that each new token is written atomically before the old one is discarded.
- Check the token request parameters. A public-client refresh normally needs
grant_type,refresh_token, andclient_id. Per RFC 6749 section 6,redirect_uriis required only if it was included in the original authorization request. Do not sendcode_verifierhere; it belongs to the code exchange. - Compare clocks. Run
date -uon the device and compare it with theDateresponse header from the token endpoint. A skew of more than a few tens of seconds is a common heuristic threshold, but the exact tolerance and leeway are server-specific. - Read the server's reason. Authorization server logs usually distinguish reuse detection, revocation, expiry, and policy change. If you cannot see them, ask the identity provider administrator for the specific
error_descriptionor audit event rather than guessing. - Check scope. If the refresh request includes
scope, it must not contain scopes that were not originally granted; that case typically returnsinvalid_scope, notinvalid_grant. Requesting minimal scopes at authorization time avoids the situation.
A refresh request to compare against
Run this from a development machine against a test account. You need a valid refresh token and the public client's client_id. Risk: shell history and terminal logs capture the token, so use an environment variable and unset it afterwards. This is a template, not a tested transcript.
export REFRESH_TOKEN='<test-account-refresh-token>'
curl -sS -X POST 'https://<issuer>/oauth2/token' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=refresh_token' \
-d "refresh_token=${REFRESH_TOKEN}" \
-d 'client_id=<public-client-id>'
unset REFRESH_TOKEN
Expected check: a success response carries access_token and token_type; whether it also carries a new refresh_token depends on the server's rotation policy. A failure carries error and usually error_description. Compare the actual shape with your provider's documented response instead of assuming it.
Fixes tied to what you found
- Reuse detected: discard the entire local token set and force interactive re-authentication. Retrying the old token will keep failing.
- redirect_uri mismatch: use the exact registered URI, including trailing-slash consistency, wherever the parameter is required.
- Clock skew: sync device time and add a small leeway to local expiry checks so you refresh slightly early rather than after the server considers the token dead.
- Lost code_verifier: persist the verifier keyed by
statebefore opening the browser. If it is gone, restart the authorization; there is no way to reconstruct it. - Expired or revoked refresh token: implement silent re-authentication. If the token lifetime is shorter than your background interval, this is expected behavior.
- Scope mismatch: request only the scopes you need at authorization time.
Escalation criteria
Escalate to the identity provider when invalid_grant persists after a clean re-authentication with a fresh client, when several unrelated clients lose tokens at the same time, or when audit logs show a server-side policy or rotation change. Escalate internally when token storage is unencrypted, shared across users, or written by more than one process.
Limitations and how to verify
Rotation behavior, reuse-detection windows, and clock tolerance are implementation-specific. This guide assumes RFC 6749 and RFC 7636 behavior without vendor extensions, and PKCE assumptions apply to public clients. Verify by reproducing the failure with a controlled refresh using the same client_id and redirect_uri, comparing local timestamps against the token's iat and exp with the measured clock offset, then clearing stored tokens and completing a fresh authorization to confirm the failure disappears. Clearing tokens changes local state; the rollback is simply re-authenticating. Because provider behavior changes, confirm current details against your authorization server's documentation before changing production configuration.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.