PrefectHQ/fastmcp · error · TokenError

invalid_grant

invalid_grant

Error message

invalid_grant: Authorization code not found or already used.

What it means

When exchanging an authorization code for tokens, the provider checks its auth_codes dict for the submitted code. If it's absent — never issued, already consumed, or expired and purged — it raises TokenError('invalid_grant', 'Authorization code not found or already used.') per RFC 6749 §5.2.

Source

Thrown at fastmcp_slim/fastmcp/server/auth/providers/in_memory.py:175

        auth_code_obj = self.auth_codes.get(authorization_code)
        if auth_code_obj:
            if auth_code_obj.client_id != client.client_id:
                return None  # Belongs to a different client
            if auth_code_obj.expires_at < time.time():
                del self.auth_codes[authorization_code]  # Expired
                return None
            return auth_code_obj
        return None

    async def exchange_authorization_code(
        self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
    ) -> OAuthToken:
        # Authorization code should have been validated (existence, expiry, client_id match)
        # by the TokenHandler calling load_authorization_code before this.
        # We might want to re-verify or simply trust it's valid.

        if authorization_code.code not in self.auth_codes:
            raise TokenError(
                "invalid_grant", "Authorization code not found or already used."
            )

        # Consume the auth code
        del self.auth_codes[authorization_code.code]

        access_token_value = f"test_access_token_{secrets.token_hex(32)}"
        refresh_token_value = f"test_refresh_token_{secrets.token_hex(32)}"

        access_token_expires_at = int(time.time() + DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS)

        # Refresh token expiry
        refresh_token_expires_at = None
        if DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS is not None:
            refresh_token_expires_at = int(
                time.time() + DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS
            )

View on GitHub (pinned to 1f02114297)

Solutions

  1. Use each authorization code exactly once; on retry-after-failure, restart the authorization flow to get a fresh code
  2. Keep the provider instance alive across the authorize/token round trip — state is in-memory only
  3. If replaying is legitimate in tests, re-run authorize() to mint a new code instead of reusing the old one

Example fix

// before
await provider.exchange_authorization_code(client, code)  # succeeds, code deleted
await provider.exchange_authorization_code(client, code)  # invalid_grant
// after
tokens = await provider.exchange_authorization_code(client, code)  # call once, store tokens
# new flow needed for another code:
Defensive patterns

Strategy: try-catch

Validate before calling

if authorization_code.code not in provider.auth_codes:
    # code consumed or never issued — restart the authorization flow
    authorization_code = await start_authorization_flow(client)

Type guard

def code_is_fresh(provider, code) -> bool:
    return code.code in provider.auth_codes

Try / catch

try:
    tokens = await provider.exchange_authorization_code(client, authorization_code)
except TokenError as e:
    if e.error == "invalid_grant":
        tokens = await restart_authorization_flow(client)  # fresh code, single-use

Prevention

When it happens

Trigger: Calling exchange_authorization_code twice with the same code (codes are deleted after first use, per single-use rule), or with a fabricated/unknown code, or after provider restart cleared in-memory auth_codes.

Common situations: HTTP client retries the token request after a network timeout, replaying a consumed code; two concurrent token requests racing on the same code; server process restarted between /authorize and /token (in-memory state lost).

Related errors


AI-assisted analysis of PrefectHQ/fastmcp@1f02114297 (2026-08-29). Data as JSON: /api/errors/cb3de528ff7027ab. Report an issue: GitHub.