{"record":{"id":"3ea77c775eb06b9e","repo":"PrefectHQ/fastmcp","slug":"assertion-must-include-exp-claim-3ea77c","errorCode":null,"errorMessage":"Assertion must include exp claim","messagePattern":"Assertion must include exp claim","errorType":"exception","errorClass":"IdentityAssertionError","httpStatus":null,"severity":"error","filePath":"fastmcp_slim/fastmcp/server/auth/identity_assertion.py","lineNumber":389,"sourceCode":"        iss = unverified_claims.get(\"iss\")\n        if not iss or iss not in self.config.trusted_issuers:\n            raise IdentityAssertionError(f\"Untrusted assertion issuer: {iss!r}\")\n\n        # 3. Verify signature, iss, aud, and exp via JWTVerifier.\n        verifier = await self._get_verifier(iss)\n        access_token = await verifier.load_access_token(assertion)\n        if access_token is None:\n            raise IdentityAssertionError(\n                \"Assertion failed signature/issuer/audience/expiry validation\"\n            )\n        claims = access_token.claims\n\n        now = time.time()\n        exp = _numeric_date_claim(claims, \"exp\")\n        iat = _numeric_date_claim(claims, \"iat\")\n        nbf = _numeric_date_claim(claims, \"nbf\")\n        if exp is None:\n            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\")","sourceCodeStart":371,"sourceCodeEnd":407,"githubUrl":"https://github.com/PrefectHQ/fastmcp/blob/1f021142978e0861cd910c8df4e8074bc7cf3978/fastmcp_slim/fastmcp/server/auth/identity_assertion.py#L371-L407","documentation":"FastMCP's identity assertion validator (ID-JAG / RFC 7523 JWT assertion flow) requires every assertion JWT to carry a numeric `exp` (expiration) claim. The JWT verifier's own expiry check can pass trivially when exp is absent, so `validate` performs an explicit post-verification check and raises `IdentityAssertionError` if `exp` is missing or is not a numeric date. This enforces RFC 7523 §3, which mandates exp on client assertions.","triggerScenarios":"Calling `IdentityAssertionMiddleware.validate()` (via an OAuth token exchange presenting an ID-JAG assertion) with a signed JWT whose payload omits the `exp` claim, or encodes `exp` as a string (e.g. \"2026-01-01\") or null rather than a NumericDate, so `_numeric_date_claim(claims, \"exp\")` returns None.","commonSituations":"Custom or misconfigured token issuers minting identity assertion JWTs without exp; hand-rolled JWT construction in tests that only sets iss/sub/aud; an issuer serializing exp as an ISO string instead of epoch seconds.","solutions":["Configure the assertion issuer (IdP or token minting code) to always include `exp` as a NumericDate (epoch seconds) in the JWT payload.","If minting assertions yourself, add `exp: int(time.time()) + lifetime` to the claims dict before signing.","Check the issuer's JWT library settings — some disable default claim injection; enable expiry claims.","Decode the assertion (e.g. jwt.io or `decode_jwt` utilities) to confirm exp is present and numeric."],"exampleFix":"// before\nclaims = {\"iss\": issuer, \"sub\": user, \"aud\": server_url, \"iat\": now}\n// after\nclaims = {\"iss\": issuer, \"sub\": user, \"aud\": server_url, \"iat\": now, \"exp\": now + 300}","handlingStrategy":"validation","validationCode":"import time\n\ndef has_valid_exp(claims: dict) -> bool:\n    exp = claims.get(\"exp\")\n    return isinstance(exp, (int, float)) and not isinstance(exp, bool) and exp > time.time()","typeGuard":"def is_numeric_date(v) -> bool:\n    return isinstance(v, (int, float)) and not isinstance(v, bool)","tryCatchPattern":"try:\n    token = await exchange(assertion)\nexcept IdentityAssertionError as e:\n    if \"exp claim\" in str(e):\n        assertion = mint_assertion(include_exp=True)\n        token = await exchange(assertion)\n    else:\n        raise","preventionTips":["Always set exp when minting assertion JWTs; make it part of a shared claims-builder helper.","Assert in tests that every minted assertion contains numeric exp/iat/jti/sub.","Validate assertions locally with a JWT decoder before sending them in a token exchange."],"tags":["auth","jwt","oauth","identity-assertion"],"backgroundTag":"jwt-missing-exp-claim","analyzedSha":"1f021142978e0861cd910c8df4e8074bc7cf3978","analyzedAt":"2026-08-29T14:31:16.082Z","schemaVersion":2},"datasetVersion":"2026-08-29T17:17:51.833Z"}