{"record":{"id":"3de1249e62356c9e","repo":"PrefectHQ/fastmcp","slug":"assertion-missing-required-scopes-sorted-missing","errorCode":null,"errorMessage":"Assertion missing required scopes: {sorted(missing)}","messagePattern":"Assertion missing required scopes: (.+?)","errorType":"validation","errorClass":"IdentityAssertionError","httpStatus":null,"severity":"error","filePath":"fastmcp_slim/fastmcp/server/auth/identity_assertion.py","lineNumber":414,"sourceCode":"                raise IdentityAssertionError(\n                    f\"Assertion lifetime too long (max {self.MAX_ASSERTION_LIFETIME}s)\"\n                )\n        elif exp > now + self.MAX_ASSERTION_LIFETIME:\n            raise IdentityAssertionError(\n                f\"Assertion exp too far in future (max {self.MAX_ASSERTION_LIFETIME}s)\"\n            )\n\n        # 4. sub is mandatory (RFC 7523 §3) — it identifies the end user.\n        sub = claims.get(\"sub\")\n        if not sub:\n            raise IdentityAssertionError(\"Assertion must include sub claim\")\n\n        # 5. Required scopes on the issued access token derive from the assertion.\n        if self.config.required_scopes:\n            granted = set(_assertion_scopes(claims))\n            missing = set(self.config.required_scopes) - granted\n            if missing:\n                raise IdentityAssertionError(\n                    f\"Assertion missing required scopes: {sorted(missing)}\"\n                )\n\n        # 6. The signed client_id and resource claims bind the assertion to the\n        # presenting client and this server. Checked here — before jti is\n        # recorded as consumed below — so an assertion presented with the\n        # wrong binding is rejected without burning replay protection for\n        # whichever client/server it actually belongs to.\n        assertion_client_id = claims.get(\"client_id\")\n        if not assertion_client_id or assertion_client_id != client_id:\n            raise IdentityAssertionError(\n                f\"Assertion client_id {assertion_client_id!r} does not match \"\n                f\"authenticated client {client_id!r}\"\n            )\n        if resource_url is not None:\n            assertion_resource = claims.get(\"resource\")\n            if not isinstance(assertion_resource, str) or not assertion_resource:\n                raise IdentityAssertionError(\"Assertion is missing resource claim\")","sourceCodeStart":396,"sourceCodeEnd":432,"githubUrl":"https://github.com/PrefectHQ/fastmcp/blob/1f021142978e0861cd910c8df4e8074bc7cf3978/fastmcp_slim/fastmcp/server/auth/identity_assertion.py#L396-L432","documentation":"The identity assertion config declares `required_scopes`, and the assertion's scope claims (scope/scope arrays via `_assertion_scopes`) do not grant all of them. The middleware derives the issued token's scopes from the assertion, so a missing required scope means the request cannot be authorized and is rejected with the sorted list of missing scopes.","triggerScenarios":"Calling `validate()` with an assertion whose granted scope set is a strict subset of `config.required_scopes` — e.g. config requires [\"read\", \"write\"] but the assertion only carries \"read\".","commonSituations":"Server tightened `required_scopes` in the IdentityAssertionConfig without updating the issuing IdP's client scopes; user consent at the IdP omitted a scope; scope naming mismatches (e.g. `read:data` vs `read`).","solutions":["Update the issuer/IdP client configuration to grant all required scopes in the assertion.","Have the user re-authenticate/re-consent so the new scopes appear in issued assertions.","Align scope names exactly between config.required_scopes and what the issuer emits.","As a last resort, the server operator can trim `required_scopes` in the config to what the issuer legitimately provides."],"exampleFix":"// before\nclaims = {\"scope\": \"read\", ...}  # server requires [\"read\", \"write\"]\n// after\nclaims = {\"scope\": \"read write\", ...}","handlingStrategy":"validation","validationCode":"def scopes_satisfied(claims: dict, required: set[str]) -> bool:\n    granted = set()\n    scope = claims.get(\"scope\") or claims.get(\"scopes\") or []\n    if isinstance(scope, str):\n        granted = set(scope.split())\n    elif isinstance(scope, list):\n        granted = set(scope)\n    return required.issubset(granted)","typeGuard":"def grants_scopes(claims: dict, required: set[str]) -> bool:\n    s = claims.get(\"scope\", \"\")\n    return isinstance(s, str) and required.issubset(set(s.split()))","tryCatchPattern":"try:\n    token = await exchange(assertion)\nexcept IdentityAssertionError as e:\n    if \"missing required scopes\" in str(e):\n        assertion = await obtain_assertion(request_scopes=REQUIRED_SCOPES)  # re-consent flow\n        token = await exchange(assertion)\n    else:\n        raise","preventionTips":["Keep required_scopes in the server config and the IdP client's granted scopes in sync.","Re-authenticate after the server widens required_scopes.","Log granted vs required scopes during integration testing."],"tags":["auth","oauth","scopes","identity-assertion"],"backgroundTag":"insufficient-scopes","analyzedSha":"1f021142978e0861cd910c8df4e8074bc7cf3978","analyzedAt":"2026-08-29T14:31:16.082Z","schemaVersion":2},"datasetVersion":"2026-08-29T17:17:51.833Z"}