oauth2: "invalid_grant" "Malformed auth code."
A fix for OAuth 'Malformed auth code' on the code exchange: why authorization codes are single-use and short-lived, the common ways clients corrupt or reuse them (double submits, double-decoding, truncation), and how to get a clean exchange. Use when the token endpoint rejects the code right after the redirect, in SPAs and mobile apps, or when PKCE is involved. Triggers: 'Malformed auth code', 'invalid_grant'. Not for: expired refresh tokens, consent errors, or redirect URI mismatches.
oauth2: "invalid_grant" "Malformed auth code."
TL;DR
The authorization code your client sent to the token endpoint is unusable: already redeemed, expired, or mangled in transit. Codes are single-use and live about ten minutes. Check for double exchange attempts (React StrictMode double effects and naive retries burn the code on the first try), send the code exactly as received, keep redirect_uri byte-identical, and include the PKCE verifier if you used PKCE.
oauth2: "invalid_grant" "Malformed auth code."Use this when
- The token endpoint rejects the code immediately after the OAuth redirect
- It works sometimes and fails sometimes (classic double-submit)
- You just added PKCE or moved the exchange to a new client
- A mobile app or SPA handles the redirect
Not for this skill when
- Refresh tokens fail later with invalid_grant (different lifecycle stage)
- The user hits a consent wall (that happens before any code exists)
- The redirect URI itself mismatches (that error names the URI)
Steps
- Check for double exchange. The code dies on first use, so two rapid token calls mean the second always fails with exactly this error. Look for React StrictMode double-invoked effects, retry logic around the token call, or both the frontend and backend attempting the exchange. Server logs should show one exchange attempt per code.
Expected: exactly one token request per authorization code in your logs.
- Send the code exactly as received. Compare what you send against the
codequery parameter character for character: no double URL-decoding, no truncation, no added quotes. Log the code's length and first few characters only, never the full value.
Expected: the outgoing code matches the inbound query parameter exactly.
- Keep redirecturi byte-identical. The token request's redirecturi must match the authorize request's, including scheme, host, path, and trailing slash. Proxies that normalize URLs are a classic source of silent mismatch.
Expected: the two redirect_uri values are identical strings.
- Include the PKCE verifier when PKCE was used. If the authorize request sent a codechallenge, the token request must send the original codeverifier. A missing or wrong verifier fails the exchange.
Expected: with the correct verifier, the exchange returns 200 and a token pair. A clean exchange looks like:
curl -X POST https://example.com/oauth/token -d grant_type=authorization_code -d code=[paste the code exactly as received] -d redirect_uri=https://example.com/callback -d client_id=[your client id] -d code_verifier=[paste the verifier]Variant: PKCE verifier mismatch
The challenge and verifier are a matched pair from the same random string. Regenerating the verifier between the authorize call and the token call, or storing it where it gets lost (page reload clearing memory state), breaks the exchange.
Variant: code expired
Codes typically live around ten minutes. A user who starts login, gets distracted, and finishes later brings a dead code. The fix is a fresh authorize round trip, not a retry.
Variant: "code was already redeemed"
The provider's wording for the double-exchange case. Same fix as step 1: find the second exchange attempt and eliminate it.
Variant: SPA reading the code from the wrong place
Some providers return the code in the query string, others in the fragment. Reading the wrong one yields garbage that fails exactly this way. Check your provider's response mode.
Why this happens
The authorization code binds the front-channel grant (the user said yes in the browser) to the back-channel exchange (your server claiming the tokens). Making it single-use and short-lived means a leaked code is nearly worthless: an attacker who intercepts it cannot reuse it after you have, and cannot sit on it.
Edge cases and pitfalls
- Load balancers can split the authorize and token calls across regions with replication lag, so a valid code looks unknown for a few seconds. A short, bounded retry is acceptable here; an unbounded one burns the code.
- URL-decoding the code twice turns valid base64url characters into garbage. Decode once, at the boundary.
- Never log full authorization codes. They are credentials with a ten-minute life, which is plenty for an attacker reading your logs.
- Test harnesses that replay recorded OAuth flows always fail here. Recorded codes are single-use by design.
Provenance
Resolved from the public thread: https://vectle.com/posts/pst__jQtJfyviAkmSShuje3KKA
Maintainer review
No maintainer verification is recorded for this version.
This records the version a maintainer checked. It does not assert that the version is the latest upstream release.