PrefectHQ/fastmcp · error · IdentityAssertionError

Assertion exp too far in future (max {self.MAX_ASSERTION_LIF

Error message

Assertion exp too far in future (max {self.MAX_ASSERTION_LIFETIME}s)

What it means

When the assertion has no `iat` claim, the middleware falls back to checking `exp` directly against wall-clock time: an expiry more than `MAX_ASSERTION_LIFETIME` seconds in the future is rejected. This prevents clients from skipping the iat-based lifetime cap simply by omitting iat.

Source

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

        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)"
                )
        elif exp > now + self.MAX_ASSERTION_LIFETIME:
            raise IdentityAssertionError(
                f"Assertion exp too far in future (max {self.MAX_ASSERTION_LIFETIME}s)"
            )

        # 4. sub is mandatory (RFC 7523 §3) — it identifies the end user.
        sub = claims.get("sub")
        if not sub:
            raise IdentityAssertionError("Assertion must include sub claim")

        # 5. Required scopes on the issued access token derive from the assertion.
        if self.config.required_scopes:
            granted = set(_assertion_scopes(claims))
            missing = set(self.config.required_scopes) - granted
            if missing:
                raise IdentityAssertionError(
                    f"Assertion missing required scopes: {sorted(missing)}"
                )

        # 6. The signed client_id and resource claims bind the assertion to the

View on GitHub (pinned to 1f02114297)

Solutions

  1. Include `iat` in the assertion and set `exp = iat + short_lifetime`.
  2. Shorten the exp so `exp <= now + MAX_ASSERTION_LIFETIME` (e.g. now + 300).
  3. If longer windows are required, the server operator can raise `MAX_ASSERTION_LIFETIME` in the config.
  4. Verify with a JWT decoder that exp is a near-future epoch-seconds value.

Example fix

// before
claims = {"iss": iss, "sub": sub, "aud": aud, "exp": now + 7200}
// after
claims = {"iss": iss, "sub": sub, "aud": aud, "iat": now, "exp": now + 300}
Defensive patterns

Strategy: validation

Validate before calling

import time

def exp_not_too_far(claims: dict, max_lifetime: int = 300) -> bool:
    exp = claims.get("exp")
    return isinstance(exp, (int, float)) and exp <= time.time() + max_lifetime

Type guard

def has_near_exp(claims, max_lifetime=300) -> bool:
    exp = claims.get("exp")
    return isinstance(exp, (int, float)) and not isinstance(exp, bool)

Try / catch

try:
    token = await exchange(assertion)
except IdentityAssertionError as e:
    if "exp too far in future" in str(e):
        assertion = mint_assertion(iat=int(time.time()), exp=int(time.time()) + 300)
        token = await exchange(assertion)
    else:
        raise

Prevention

When it happens

Trigger: Calling `validate()` with an assertion that omits `iat` but sets `exp` more than MAX_ASSERTION_LIFETIME seconds ahead of the server's now — e.g. exp set hours/days out on an iat-less token.

Common situations: Issuers that emit exp-only JWTs (common for opaque access tokens) reused as identity assertions; hand-built test JWTs with only iss/sub/aud/exp; template tokens with long default expiries.

Related errors


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