{"record":{"id":"50ef3c4a7f2f28fb","repo":"PrefectHQ/fastmcp","slug":"invalid-scope","errorCode":"invalid_scope","errorMessage":"invalid_scope: Requested scopes exceed those authorized by the refresh token.","messagePattern":"invalid_scope: Requested scopes exceed those authorized by the refresh token\\.","errorType":"error_code","errorClass":"TokenError","httpStatus":400,"severity":"error","filePath":"fastmcp_slim/fastmcp/server/auth/providers/in_memory.py","lineNumber":245,"sourceCode":"            if token_obj.expires_at is not None and token_obj.expires_at < time.time():\n                self._revoke_internal(\n                    refresh_token_str=token_obj.token\n                )  # Clean up expired\n                return None\n            return token_obj\n        return None\n\n    async def exchange_refresh_token(\n        self,\n        client: OAuthClientInformationFull,\n        refresh_token: RefreshToken,  # This is the RefreshToken object, already loaded\n        scopes: list[str],  # Requested scopes for the new access token\n    ) -> OAuthToken:\n        # Validate scopes: requested scopes must be a subset of original scopes\n        original_scopes = set(refresh_token.scopes)\n        requested_scopes = set(scopes)\n        if not requested_scopes.issubset(original_scopes):\n            raise TokenError(\n                \"invalid_scope\",\n                \"Requested scopes exceed those authorized by the refresh token.\",\n            )\n\n        # Invalidate old refresh token and its associated access token (rotation)\n        self._revoke_internal(refresh_token_str=refresh_token.token)\n\n        # Issue new tokens\n        new_access_token_value = f\"test_access_token_{secrets.token_hex(32)}\"\n        new_refresh_token_value = f\"test_refresh_token_{secrets.token_hex(32)}\"\n\n        access_token_expires_at = int(time.time() + DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS)\n\n        # Refresh token expiry\n        refresh_token_expires_at = None\n        if DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS is not None:\n            refresh_token_expires_at = int(\n                time.time() + DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS","sourceCodeStart":227,"sourceCodeEnd":263,"githubUrl":"https://github.com/PrefectHQ/fastmcp/blob/1f021142978e0861cd910c8df4e8074bc7cf3978/fastmcp_slim/fastmcp/server/auth/providers/in_memory.py#L227-L263","documentation":"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.","triggerScenarios":"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'.","commonSituations":"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.","solutions":["Pass an empty/None scopes list on refresh so the original grant's scopes are reused","Request only scopes within the original grant: set(requested) <= set(original)","If broader scopes are genuinely needed, run a fresh authorization flow to obtain a new grant and refresh token"],"exampleFix":"// before\ntokens = await provider.exchange_refresh_token(rt, client, [\"read\", \"write\", \"admin\"])  # rt only has read/write\n// after\ntokens = await provider.exchange_refresh_token(rt, client, [])  # reuse original scopes\n# or request a new grant with 'admin' first","handlingStrategy":"validation","validationCode":"requested = scopes or []\noriginal = set(refresh_token.scopes)\nassert set(requested).issubset(original), f\"cannot escalate scopes: {set(requested) - original}\"\ntokens = await provider.exchange_refresh_token(refresh_token, client, requested)","typeGuard":"def scopes_within_grant(refresh_token, scopes: list[str] | None) -> bool:\n    return set(scopes or []).issubset(set(refresh_token.scopes))","tryCatchPattern":"try:\n    tokens = await provider.exchange_refresh_token(rt, client, scopes)\nexcept TokenError as e:\n    if e.error == \"invalid_scope\":\n        tokens = await provider.exchange_refresh_token(rt, client, [])  # reuse original scopes","preventionTips":["Default to omitting scopes on refresh to inherit the original grant","Store the granted scopes client-side and intersect before refreshing","Run a fresh authorization flow whenever new scopes are genuinely needed"],"tags":["oauth","scopes","refresh-token"],"backgroundTag":"invalid-oauth-scope","analyzedSha":"1f021142978e0861cd910c8df4e8074bc7cf3978","analyzedAt":"2026-08-29T14:31:16.082Z","schemaVersion":2},"datasetVersion":"2026-08-29T17:17:51.833Z"}