PrefectHQ/fastmcp · error · TokenError

invalid_scope

invalid_scope

Error message

invalid_scope: Requested scopes exceed those authorized by the refresh token.

What it means

During refresh-token exchange, the requested scopes for the new access token must be a subset of the scopes originally granted with the refresh token. Requesting anything beyond that raises TokenError('invalid_scope', ...) per RFC 6749 §6, which forbids privilege escalation through refresh.

Source

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

            if token_obj.expires_at is not None and token_obj.expires_at < time.time():
                self._revoke_internal(
                    refresh_token_str=token_obj.token
                )  # Clean up expired
                return None
            return token_obj
        return None

    async def exchange_refresh_token(
        self,
        client: OAuthClientInformationFull,
        refresh_token: RefreshToken,  # This is the RefreshToken object, already loaded
        scopes: list[str],  # Requested scopes for the new access token
    ) -> OAuthToken:
        # Validate scopes: requested scopes must be a subset of original scopes
        original_scopes = set(refresh_token.scopes)
        requested_scopes = set(scopes)
        if not requested_scopes.issubset(original_scopes):
            raise TokenError(
                "invalid_scope",
                "Requested scopes exceed those authorized by the refresh token.",
            )

        # Invalidate old refresh token and its associated access token (rotation)
        self._revoke_internal(refresh_token_str=refresh_token.token)

        # Issue new tokens
        new_access_token_value = f"test_access_token_{secrets.token_hex(32)}"
        new_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. Pass an empty/None scopes list on refresh so the original grant's scopes are reused
  2. Request only scopes within the original grant: set(requested) <= set(original)
  3. If broader scopes are genuinely needed, run a fresh authorization flow to obtain a new grant and refresh token

Example fix

// before
tokens = await provider.exchange_refresh_token(rt, client, ["read", "write", "admin"])  # rt only has read/write
// after
tokens = await provider.exchange_refresh_token(rt, client, [])  # reuse original scopes
# or request a new grant with 'admin' first
Defensive patterns

Strategy: validation

Validate before calling

requested = scopes or []
original = set(refresh_token.scopes)
assert set(requested).issubset(original), f"cannot escalate scopes: {set(requested) - original}"
tokens = await provider.exchange_refresh_token(refresh_token, client, requested)

Type guard

def scopes_within_grant(refresh_token, scopes: list[str] | None) -> bool:
    return set(scopes or []).issubset(set(refresh_token.scopes))

Try / catch

try:
    tokens = await provider.exchange_refresh_token(rt, client, scopes)
except TokenError as e:
    if e.error == "invalid_scope":
        tokens = await provider.exchange_refresh_token(rt, client, [])  # reuse original scopes

Prevention

When it happens

Trigger: exchange_refresh_token(refresh_token, client, scopes) where set(scopes) contains any scope not present in refresh_token.scopes — e.g. asking for 'admin' when the original grant was only 'read write'.

Common situations: Client app hardcodes its full desired scope list in the refresh call instead of omitting scopes (omitting usually means reuse original); app was updated to need new scopes after the original consent; scope-name drift between grant and refresh requests.

Related errors


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