PrefectHQ/fastmcp · error · ValueError
Assertion must include jti claim
Error message
Assertion must include jti claim
What it means
Raised by validate_assertion when the client assertion lacks the 'jti' (JWT ID) claim. RFC 7523 recommends jti for replay prevention; this implementation requires it so every assertion has a unique identifier it can track in its replay cache.
Source
Thrown at fastmcp_slim/fastmcp/server/auth/cimd.py:623
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:
raise ValueError(f"Assertion sub claim must be {client_id}")
# 5. Check jti for replay attacks (RFC 7523 requirement)
jti = claims.get("jti")
if not jti:
raise ValueError("Assertion must include jti claim")
# Check if JTI was already used (and hasn't expired from cache)
if jti in self._jti_cache:
cached_exp = self._jti_cache[jti]
if cached_exp > now: # Still valid in cache
raise ValueError(f"Assertion replay detected: jti {jti} already used")
# Expired in cache, can be reused (clean it up)
del self._jti_cache[jti]
# Emergency size limit (shouldn't hit with proper TTL cleanup)
if len(self._jti_cache) >= self._jti_cache_max_size:
self._cleanup_expired_jtis()
# If still over limit after cleanup, reject to prevent DoS
if len(self._jti_cache) >= self._jti_cache_max_size:
self.logger.warning(
"JTI cache at max capacity (%d), possible attack",
self._jti_cache_max_size,
)View on GitHub (pinned to 1f02114297)
Solutions
- Add a unique 'jti' value (e.g. str(uuid.uuid4())) to every assertion payload
- Generate a fresh jti per assertion, never reuse it while the old assertion is unexpired
- Update assertion templates/SDK settings to include jti by default
Example fix
// before
payload = {"iss": client_id, "sub": client_id, "aud": aud, "iat": now, "exp": now + 300}
// after
payload = {"iss": client_id, "sub": client_id, "aud": aud, "iat": now, "exp": now + 300,
"jti": str(uuid.uuid4())} Defensive patterns
Strategy: validation
Validate before calling
import uuid
claims = jwt.decode(token, options={"verify_signature": False})
if not claims.get("jti"):
token = mint_assertion(client_id, jti=str(uuid.uuid4())) Type guard
def has_jti(claims: dict) -> bool:
return bool(claims.get("jti")) Try / catch
try:
validator.validate_assertion(token, client_id, jwks)
except ValueError as e:
if "must include jti" in str(e):
token = mint_assertion(client_id, jti=str(uuid.uuid4()))
else:
raise Prevention
- Always generate a fresh uuid4 jti per assertion
- Keep jti in your claim template/checklist
- Test minted assertions for exp/iat/jti presence
When it happens
Trigger: Minting an assertion payload without jti; using a JWT helper whose default claim set omits jti; stripping optional claims during token serialization.
Common situations: Hand-rolled assertion builders; minimal test fixtures that omit jti; older minting code predating replay-protection requirements.
Related errors
- Assertion sub claim must be {client_id}
- Invalid client_assertion_type: expected {JWT_BEARER_ASSERTIO
- Missing client_assertion
- Invalid client assertion: {e}
- CIMD document must have jwks_uri or jwks for private_key_jwt
AI-assisted analysis of PrefectHQ/fastmcp@1f02114297 (2026-08-29).
Data as JSON: /api/errors/fe2587ae7bed8eac.
Report an issue: GitHub.