{"record":{"id":"4584b0e3ab1f7f6b","repo":"PrefectHQ/fastmcp","slug":"assertion-sub-claim-must-be-client-id","errorCode":null,"errorMessage":"Assertion sub claim must be {client_id}","messagePattern":"Assertion sub claim must be (.+?)","errorType":"exception","errorClass":"ValueError","httpStatus":null,"severity":"error","filePath":"fastmcp_slim/fastmcp/server/auth/cimd.py","lineNumber":618,"sourceCode":"\n        # If iat is present, validate it and check assertion lifetime\n        if iat:\n            if iat > now + 30:  # 30 second clock skew tolerance\n                raise ValueError(\"Assertion iat is in the future\")\n            if exp - iat > self.MAX_ASSERTION_LIFETIME:\n                raise ValueError(\n                    f\"Assertion lifetime too long: {exp - iat}s (max {self.MAX_ASSERTION_LIFETIME}s)\"\n                )\n        else:\n            # No iat, enforce max lifetime from now\n            if exp > now + self.MAX_ASSERTION_LIFETIME:\n                raise ValueError(\n                    f\"Assertion exp too far in future (max {self.MAX_ASSERTION_LIFETIME}s)\"\n                )\n\n        # 4. Additional RFC 7523 validation: sub claim must equal client_id\n        if claims.get(\"sub\") != client_id:\n            raise ValueError(f\"Assertion sub claim must be {client_id}\")\n\n        # 5. Check jti for replay attacks (RFC 7523 requirement)\n        jti = claims.get(\"jti\")\n        if not jti:\n            raise ValueError(\"Assertion must include jti claim\")\n\n        # Check if JTI was already used (and hasn't expired from cache)\n        if jti in self._jti_cache:\n            cached_exp = self._jti_cache[jti]\n            if cached_exp > now:  # Still valid in cache\n                raise ValueError(f\"Assertion replay detected: jti {jti} already used\")\n            # Expired in cache, can be reused (clean it up)\n            del self._jti_cache[jti]\n\n        # Emergency size limit (shouldn't hit with proper TTL cleanup)\n        if len(self._jti_cache) >= self._jti_cache_max_size:\n            self._cleanup_expired_jtis()\n            # If still over limit after cleanup, reject to prevent DoS","sourceCodeStart":600,"sourceCodeEnd":636,"githubUrl":"https://github.com/PrefectHQ/fastmcp/blob/1f021142978e0861cd910c8df4e8074bc7cf3978/fastmcp_slim/fastmcp/server/auth/cimd.py#L600-L636","documentation":"Raised by validate_assertion because the JWT's 'sub' (subject) claim does not equal the client_id being authenticated. RFC 7523 mandates sub == client_id for private_key_jwt so the assertion unambiguously identifies the client; a mismatch means the assertion may be for a different client.","triggerScenarios":"Passing client_id X to validate_private_key_jwt while the token was signed with sub = issuer URL or a different client's ID; using the authorization-server issuer URL as sub where this implementation expects the client_id; copy-pasted token minting code with a stale/substituted client_id.","commonSituations":"Multi-tenant setups where the assertion template hardcodes one client's ID; CIMD clients setting sub to their metadata URL instead of the client_id; rotated client IDs after re-registration.","solutions":["Set the JWT 'sub' claim exactly equal to the client_id passed to the validator","Fix the assertion-minting code to parameterize sub from the actual client_id","Verify you are validating against the correct client_id (not an issuer/subject URL)"],"exampleFix":"// before\npayload[\"sub\"] = issuer_url\n// after\npayload[\"sub\"] = client_id","handlingStrategy":"validation","validationCode":"claims = jwt.decode(token, options={\"verify_signature\": False})\nif claims.get(\"sub\") != client_id:\n    raise ValueError(f\"assertion sub ({claims.get('sub')}) must equal client_id ({client_id})\")","typeGuard":"def sub_matches_client(claims: dict, client_id: str) -> bool:\n    return claims.get(\"sub\") == client_id","tryCatchPattern":"try:\n    validator.validate_assertion(token, client_id, jwks)\nexcept ValueError as e:\n    if \"sub claim must be\" in str(e):\n        token = mint_assertion(client_id)  # parameterize sub from client_id\n    else:\n        raise","preventionTips":["Always set sub = client_id in private_key_jwt assertions","Parameterize sub in minting code; never hardcode it","Add a pre-flight assertion self-check in tests"],"tags":["oauth","jwt","rfc7523","validation"],"backgroundTag":"jwt-subject-mismatch","analyzedSha":"1f021142978e0861cd910c8df4e8074bc7cf3978","analyzedAt":"2026-08-29T14:31:16.082Z","schemaVersion":2},"datasetVersion":"2026-08-29T17:17:51.833Z"}