PrefectHQ/fastmcp · error · IdentityAssertionError

Assertion failed signature/issuer/audience/expiry validation

Error message

Assertion failed signature/issuer/audience/expiry validation

What it means

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.

Source

Thrown at fastmcp_slim/fastmcp/server/auth/identity_assertion.py:379

                f"Assertion typ must be {ID_JAG_TYP!r}, got {header.get('typ')!r}"
            )

        # 2. iss must be a trusted issuer before we fetch any keys for it.
        try:
            unverified_claims = _decode_unverified_claims(assertion)
        except (ValueError, KeyError, IndexError) as e:
            raise IdentityAssertionError(f"Malformed assertion payload: {e}") from e
        if not isinstance(unverified_claims, dict):
            raise IdentityAssertionError("Assertion payload is not a JSON object")
        iss = unverified_claims.get("iss")
        if not iss or iss not in self.config.trusted_issuers:
            raise IdentityAssertionError(f"Untrusted assertion issuer: {iss!r}")

        # 3. Verify signature, iss, aud, and exp via JWTVerifier.
        verifier = await self._get_verifier(iss)
        access_token = await verifier.load_access_token(assertion)
        if access_token is None:
            raise IdentityAssertionError(
                "Assertion failed signature/issuer/audience/expiry validation"
            )
        claims = access_token.claims

        now = time.time()
        exp = _numeric_date_claim(claims, "exp")
        iat = _numeric_date_claim(claims, "iat")
        nbf = _numeric_date_claim(claims, "nbf")
        if exp is None:
            raise IdentityAssertionError("Assertion must include exp claim")
        if nbf is not None and nbf > now + self.CLOCK_SKEW_SECONDS:
            raise IdentityAssertionError("Assertion is not yet valid (nbf in future)")
        if iat is not None:
            if iat > now + self.CLOCK_SKEW_SECONDS:
                raise IdentityAssertionError("Assertion iat is in the future")
            if exp - iat > self.MAX_ASSERTION_LIFETIME:
                raise IdentityAssertionError(
                    f"Assertion lifetime too long (max {self.MAX_ASSERTION_LIFETIME}s)"

View on GitHub (pinned to 1f02114297)

Solutions

  1. Check server clock synchronization (NTP) and that the assertion's exp is in the future.
  2. Confirm the audience: the assertion's aud must match what the server/exchange expects; fix client aud configuration if wrong.
  3. Have the client re-obtain a fresh assertion via the id-jag exchange instead of reusing a cached/expired one.
  4. If keys were rotated, clear/restart so the verifier refetches JWKS, and verify the token with the IdP's debugger to confirm the signature.

Example fix

// before: reusing a cached assertion across hours
assertion = cached_assertion_from_yesterday
// after: perform a fresh id-jag exchange before each authorization-grant exchange
assertion = await client.exchange_id_token_for_assertion(id_token)
Defensive patterns

Strategy: try-catch

Validate before calling

import time, base64, json
claims = json.loads(base64.urlsafe_b64decode(assertion.split('.')[1] + '=='))
assert claims.get('exp', 0) > time.time(), 'assertion expired'
assert claims.get('aud') == EXPECTED_AUDIENCE, 'audience mismatch'

Type guard

def assertion_is_current(token: str, aud: str, skew: int = 60) -> bool:
    import base64, json, time
    c = json.loads(base64.urlsafe_b64decode(token.split('.')[1] + '=='))
    return c.get('exp', 0) + skew > time.time() and aud in (c.get('aud') or [])

Try / catch

try:
    await provider.validate(assertion)
except IdentityAssertionError as e:
    if 'signature/issuer/audience/expiry' in str(e):
        # get a fresh assertion; do not retry with the same token
        assertion = await obtain_fresh_assertion()
    else:
        raise

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


AI-assisted analysis of PrefectHQ/fastmcp@1f02114297 (2026-08-29). Data as JSON: /api/errors/2fd3897404e449cc. Report an issue: GitHub.