{"record":{"id":"2fd3897404e449cc","repo":"PrefectHQ/fastmcp","slug":"assertion-failed-signature-issuer-audience-expiry","errorCode":null,"errorMessage":"Assertion failed signature/issuer/audience/expiry validation","messagePattern":"Assertion failed signature/issuer/audience/expiry validation","errorType":"exception","errorClass":"IdentityAssertionError","httpStatus":null,"severity":"error","filePath":"fastmcp_slim/fastmcp/server/auth/identity_assertion.py","lineNumber":379,"sourceCode":"                f\"Assertion typ must be {ID_JAG_TYP!r}, got {header.get('typ')!r}\"\n            )\n\n        # 2. iss must be a trusted issuer before we fetch any keys for it.\n        try:\n            unverified_claims = _decode_unverified_claims(assertion)\n        except (ValueError, KeyError, IndexError) as e:\n            raise IdentityAssertionError(f\"Malformed assertion payload: {e}\") from e\n        if not isinstance(unverified_claims, dict):\n            raise IdentityAssertionError(\"Assertion payload is not a JSON object\")\n        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)\"","sourceCodeStart":361,"sourceCodeEnd":397,"githubUrl":"https://github.com/PrefectHQ/fastmcp/blob/1f021142978e0861cd910c8df4e8074bc7cf3978/fastmcp_slim/fastmcp/server/auth/identity_assertion.py#L361-L397","documentation":"After selecting a verifier for the trusted issuer, JWTVerifier.load_access_token fully validated the assertion (signature against the issuer's JWKS, iss, aud, exp) and returned None, meaning cryptographic or standard-claim validation failed. FastMCP collapses all of these into one message to avoid leaking verification details to callers.","triggerScenarios":"validate() on an assertion that is structurally fine and from a trusted issuer, but whose signature doesn't verify against the issuer's current JWKS, whose aud doesn't match the expected audience, whose exp is past, or whose iss inside verification disagrees.","commonSituations":"Clock skew between IdP and server making exp seem passed; token expired after client cached it; IdP rotated signing keys and the server cached stale JWKS; audience/audience (client_id/resource) misconfiguration; token signed by a different key/tenant than expected.","solutions":["Check server clock synchronization (NTP) and that the assertion's exp is in the future.","Confirm the audience: the assertion's aud must match what the server/exchange expects; fix client aud configuration if wrong.","Have the client re-obtain a fresh assertion via the id-jag exchange instead of reusing a cached/expired one.","If keys were rotated, clear/restart so the verifier refetches JWKS, and verify the token with the IdP's debugger to confirm the signature."],"exampleFix":"// before: reusing a cached assertion across hours\nassertion = cached_assertion_from_yesterday\n// after: perform a fresh id-jag exchange before each authorization-grant exchange\nassertion = await client.exchange_id_token_for_assertion(id_token)","handlingStrategy":"try-catch","validationCode":"import time, base64, json\nclaims = json.loads(base64.urlsafe_b64decode(assertion.split('.')[1] + '=='))\nassert claims.get('exp', 0) > time.time(), 'assertion expired'\nassert claims.get('aud') == EXPECTED_AUDIENCE, 'audience mismatch'","typeGuard":"def assertion_is_current(token: str, aud: str, skew: int = 60) -> bool:\n    import base64, json, time\n    c = json.loads(base64.urlsafe_b64decode(token.split('.')[1] + '=='))\n    return c.get('exp', 0) + skew > time.time() and aud in (c.get('aud') or [])","tryCatchPattern":"try:\n    await provider.validate(assertion)\nexcept IdentityAssertionError as e:\n    if 'signature/issuer/audience/expiry' in str(e):\n        # get a fresh assertion; do not retry with the same token\n        assertion = await obtain_fresh_assertion()\n    else:\n        raise","preventionTips":["Sync server clocks with NTP","Always fetch a fresh assertion instead of caching expired ones","Confirm aud configuration matches between client and server","Restart/refetch JWKS after IdP key rotation","Validate tokens with the IdP's debugger when signature checks fail"],"tags":["jwt","signature","expired","audience","security"],"backgroundTag":"jwt-validation-failed","analyzedSha":"1f021142978e0861cd910c8df4e8074bc7cf3978","analyzedAt":"2026-08-29T14:31:16.082Z","schemaVersion":2},"datasetVersion":"2026-08-29T17:17:51.833Z"}