PrefectHQ/fastmcp · error · ValueError

Assertion must include exp claim

Error message

Assertion must include exp claim

What it means

This ValueError is raised by CIMDValidator.validate_assertion when the private_key_jwt client assertion (a JWT) lacks the mandatory 'exp' (expiration) claim. RFC 7523 requires exp so the server can bound the assertion's lifetime; without it the assertion could never expire, so the library refuses it outright before any other lifetime checks.

Source

Thrown at fastmcp_slim/fastmcp/server/auth/cimd.py:595

        else:
            raise ValueError(
                "CIMD document must have jwks_uri or jwks for private_key_jwt"
            )

        # 2. Verify JWT using JWTVerifier (handles signature, exp, iss, aud)
        access_token = await verifier.load_access_token(assertion)
        if not access_token:
            raise ValueError("Invalid JWT assertion")

        claims = access_token.claims

        # 3. Validate assertion lifetime (exp and iat)
        now = time.time()
        exp = claims.get("exp")
        iat = claims.get("iat")

        if not exp:
            raise ValueError("Assertion must include exp claim")

        # Validate exp is in the future (with small clock skew tolerance)
        if exp < now - 30:  # 30 second clock skew tolerance
            raise ValueError("Assertion has expired")

        # If iat is present, validate it and check assertion lifetime
        if iat:
            if iat > now + 30:  # 30 second clock skew tolerance
                raise ValueError("Assertion iat is in the future")
            if exp - iat > self.MAX_ASSERTION_LIFETIME:
                raise ValueError(
                    f"Assertion lifetime too long: {exp - iat}s (max {self.MAX_ASSERTION_LIFETIME}s)"
                )
        else:
            # No iat, enforce max lifetime from now
            if exp > now + self.MAX_ASSERTION_LIFETIME:
                raise ValueError(
                    f"Assertion exp too far in future (max {self.MAX_ASSERTION_LIFETIME}s)"

View on GitHub (pinned to 1f02114297)

Solutions

  1. Add an 'exp' claim (Unix timestamp, seconds) to the assertion payload, typically now + 300 for a 5-minute lifetime
  2. Ensure exp is a numeric (int) timestamp, not an ISO string, since the code compares exp < now - 30
  3. Regenerate the assertion with a standard OIDC-compliant JWT helper that always sets exp

Example fix

// before
payload = {"iss": client_id, "sub": client_id, "aud": token_endpoint, "iat": now, "jti": jti}
// after
payload = {"iss": client_id, "sub": client_id, "aud": token_endpoint, "iat": now,
           "exp": now + 300, "jti": jti}
Defensive patterns

Strategy: validation

Validate before calling

import time
claims = jwt.decode(assertion, options={"verify_signature": False})
if not claims.get("exp"):
    raise ValueError("assertion payload must include a numeric exp claim")
if not isinstance(claims["exp"], (int, float)):
    raise TypeError("exp must be a Unix timestamp")

Type guard

def has_exp(claims: dict) -> bool:
    exp = claims.get("exp")
    return isinstance(exp, (int, float)) and exp > 0

Prevention

When it happens

Trigger: Calling validate_assertion (directly or via validate_private_key_jwt) with a signed JWT whose payload has no 'exp' claim — e.g. a hand-built assertion payload dict that only includes iss/sub/aud/iat/jti, or a token minted by a custom JWT helper that omits exp.

Common situations: Developers manually constructing client assertions for OAuth private_key_jwt auth and forgetting exp; migrating from a token format that didn't require exp; using a JWT library where exp is optional by default.

Related errors


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