PrefectHQ/fastmcp · error · TokenError
invalid_grant
invalid_grant
Error message
Authorization code not found
What it means
Raised during the OAuth proxy token exchange when the authorization code presented by the client is not found in the proxy's code store. The proxy stores authorization codes it issued and looks them up by key when the client redeems the code at /token; a miss means the code was never issued by this server, already consumed (codes are single-use per RFC 6749), or expired/purged from storage. The client receives an OAuth TokenError with code 'invalid_grant'.
Source
Thrown at fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py:1311
"""Exchange authorization code for FastMCP-issued tokens.
Implements the token factory pattern:
1. Retrieves upstream tokens from stored authorization code
2. Extracts user identity from upstream token
3. Encrypts and stores upstream tokens
4. Issues FastMCP-signed JWT tokens
5. Returns FastMCP tokens (NOT upstream tokens)
PKCE validation is handled by the MCP framework before this method is called.
"""
# Look up stored code data
code_model = await self._code_store.get(key=authorization_code.code)
if not code_model:
logger.error(
"Authorization code not found in client codes: %s",
authorization_code.code,
)
raise TokenError("invalid_grant", "Authorization code not found")
# Get stored upstream tokens
idp_tokens = code_model.idp_tokens
# Use IdP-granted scopes when available (RFC 6749 §5.1: the IdP MUST
# include a scope parameter when the granted scope differs from the
# requested scope). Fall back to requested scopes only when the IdP
# omits scope, meaning it granted exactly what was requested.
granted_scopes: list[str] = (
parse_scopes(idp_tokens["scope"]) or []
if "scope" in idp_tokens
else list(authorization_code.scopes)
)
# Translate IdP-wire scopes into the client-facing form before they
# propagate to storage, the FastMCP JWT, and the response body. Default
# implementation is identity; AzureProvider overrides this to strip the
# identifier_uri prefix Azure echoes back on custom API scopes.
granted_scopes = self._translate_scopes_from_idp(granted_scopes)View on GitHub (pinned to 1f02114297)
Solutions
- Do not reuse authorization codes — restart the OAuth flow and obtain a fresh code from the authorization endpoint.
- If codes must survive restarts or multiple replicas, configure a persistent/shared storage backend for the code store instead of the default in-memory store.
- Check that the authorization and token requests hit the same OAuth proxy server (same base URL / instance).
- Verify the client is not caching or replaying codes; each redirect should trigger a single exchange.
Example fix
// before: replaying a stored code
const code = savedCode; // already exchanged once
await exchangeToken(code);
// after: always use the fresh code from the current redirect
const code = new URL(callbackUrl).searchParams.get("code");
await exchangeToken(code); Defensive patterns
Strategy: validation
Validate before calling
// before exchanging, ensure you hold a fresh code from this redirect
const code = new URL(callbackUrl).searchParams.get("code");
if (!code) throw new Error("missing authorization code");
if (code === lastExchangedCode) throw new Error("authorization code already used"); Try / catch
try {
await exchangeAuthorizationCode(code);
} catch (e) {
if (e.code === "invalid_grant") {
restartOAuthFlow(); // obtain a fresh code
} else throw e;
} Prevention
- Never reuse or cache authorization codes; exchange each code exactly once
- Use persistent/shared storage for the code store in multi-instance or restart-prone deployments
- Hit the same server instance/deployment for authorize and token endpoints
When it happens
Trigger: Calling POST /token with grant_type=authorization_code and a code that is not in the proxy's _code_store: a reused (already-redeemed) code, a code from a different server/environment, an expired or evicted entry (e.g. in-memory store restarted), or a tampered code value.
Common situations: Retrying a token exchange after an earlier success (code was single-use and deleted); client caches an auth code across redirects; server restart with an in-memory code store losing issued codes; load-balanced instances sharing no storage; clock skew or storage TTL expiring the code before redemption.
Related errors
- invalid_grant
- The device authorization request expired
- The device authorization request was denied
- Device authorization failed
- OAuth client not found - cached credentials may be stale
AI-assisted analysis of PrefectHQ/fastmcp@1f02114297 (2026-08-29).
Data as JSON: /api/errors/8863edc20bc5703b.
Report an issue: GitHub.