{"record":{"id":"4108c0657bdd7a48","repo":"PrefectHQ/fastmcp","slug":"assertion-must-include-sub-claim","errorCode":null,"errorMessage":"Assertion must include sub claim","messagePattern":"Assertion must include sub claim","errorType":"validation","errorClass":"IdentityAssertionError","httpStatus":null,"severity":"error","filePath":"fastmcp_slim/fastmcp/server/auth/identity_assertion.py","lineNumber":407,"sourceCode":"            raise IdentityAssertionError(\"Assertion must include exp claim\")\n        if nbf is not None and nbf > now + self.CLOCK_SKEW_SECONDS:\n            raise IdentityAssertionError(\"Assertion is not yet valid (nbf in future)\")\n        if iat is not None:\n            if iat > now + self.CLOCK_SKEW_SECONDS:\n                raise IdentityAssertionError(\"Assertion iat is in the future\")\n            if exp - iat > self.MAX_ASSERTION_LIFETIME:\n                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(","sourceCodeStart":389,"sourceCodeEnd":425,"githubUrl":"https://github.com/PrefectHQ/fastmcp/blob/1f021142978e0861cd910c8df4e8074bc7cf3978/fastmcp_slim/fastmcp/server/auth/identity_assertion.py#L389-L425","documentation":"RFC 7523 §3 makes `sub` (subject) mandatory: it identifies the end user on whose behalf the assertion is presented. FastMCP's identity assertion validator raises this error when the verified JWT claims lack a `sub` or it is empty, since a subject-less assertion cannot yield an identity for the issued access token.","triggerScenarios":"Calling `validate()` with an assertion whose payload has no `sub` claim, `sub: null`, or `sub: \"\"` — typically from an issuer configured for machine-only tokens or a custom minting function that forgot the subject.","commonSituations":"Service-to-service token templates that omit sub; custom assertion minters copying only iss/aud/exp; IdPs issuing anonymous or pre-auth tokens repurposed as identity assertions.","solutions":["Configure the issuer to include the end user's identifier as `sub` in the assertion payload.","If minting assertions in code, add `sub: <user_id>` before signing.","Check IdP claim-mapping/transform rules that may strip or rename sub (e.g. mapped to user_id).","Decode the JWT and confirm `sub` is a non-empty string."],"exampleFix":"// before\nclaims = {\"iss\": iss, \"aud\": aud, \"exp\": now + 300, \"jti\": jti}\n// after\nclaims = {\"iss\": iss, \"sub\": user_id, \"aud\": aud, \"exp\": now + 300, \"jti\": jti}","handlingStrategy":"validation","validationCode":"def has_sub(claims: dict) -> bool:\n    sub = claims.get(\"sub\")\n    return isinstance(sub, str) and bool(sub)","typeGuard":"def has_nonempty_str(claims: dict, key: str) -> bool:\n    v = claims.get(key)\n    return isinstance(v, str) and bool(v)","tryCatchPattern":"try:\n    token = await exchange(assertion)\nexcept IdentityAssertionError as e:\n    if \"sub claim\" in str(e):\n        raise ValueError(\"Issuer misconfiguration: assertions lack a subject\") from e\n    raise","preventionTips":["Make sub a mandatory field in your assertion minting helper's signature.","Audit IdP claim-mapping rules for strips/renames of sub.","Add a unit test asserting every minted assertion has a non-empty sub."],"tags":["auth","jwt","oauth","identity-assertion"],"backgroundTag":"jwt-missing-sub-claim","analyzedSha":"1f021142978e0861cd910c8df4e8074bc7cf3978","analyzedAt":"2026-08-29T14:31:16.082Z","schemaVersion":2},"datasetVersion":"2026-08-29T17:17:51.833Z"}