PrefectHQ/fastmcp · error · ValueError

Assertion has expired

Error message

Assertion has expired

What it means

Raised by validate_assertion when the assertion's 'exp' claim is older than the current time minus a 30-second clock-skew allowance, i.e. the JWT has expired. The library enforces assertion freshness per RFC 7523 so replayed stale assertions are rejected.

Source

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

        # 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)"
                )

        # 4. Additional RFC 7523 validation: sub claim must equal client_id
        if claims.get("sub") != client_id:

View on GitHub (pinned to 1f02114297)

Solutions

  1. Mint a fresh assertion at request time (exp = now + 300) instead of caching it
  2. Synchronize the machine clock (NTP) if drift is the cause
  3. Increase the assertion's exp window slightly if your flow legitimately needs longer validity (respecting the server's MAX_ASSERTION_LIFETIME)

Example fix

// before
exp = iat + 3600  # long-lived assertion gets cached and reused
// after
exp = int(time.time()) + 300  # mint fresh per request
Defensive patterns

Strategy: validation

Validate before calling

import time
claims = jwt.decode(token, options={"verify_signature": False})
if claims.get("exp", 0) <= time.time():
    token = mint_fresh_assertion(client_id)  # re-mint before calling

Type guard

def is_currently_valid(claims: dict, skew: float = 30) -> bool:
    return isinstance(claims.get("exp"), (int, float)) and claims["exp"] > time.time() - skew

Try / catch

try:
    validator.validate_assertion(token, client_id, jwks)
except ValueError as e:
    if "expired" in str(e):
        token = mint_fresh_assertion(client_id)
        validator.validate_assertion(token, client_id, jwks)
    else:
        raise

Prevention

When it happens

Trigger: Presenting a cached or long-lived client assertion after its exp timestamp passed; a machine clock set more than 30 seconds behind the server's; reusing a persisted token file from a previous session.

Common situations: Clients caching the signed assertion instead of minting a fresh one per token request; VMs/containers with clock drift; slow requests where a short (e.g. 60s) exp lapsed before validation.

Related errors


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