PrefectHQ/fastmcp · error · IdentityAssertionError
Assertion replay detected: jti {jti} reused
Error message
Assertion replay detected: jti {jti} reused What it means
Raised when the jti claim of an assertion matches an entry already in the validator's replay cache whose expiration is still in the future — i.e. the exact same assertion JWT (same jti) was accepted before and has not yet expired. This implements RFC 7523 §3 replay prevention: a bearer assertion intercepted or re-sent must be rejected with a clean IdentityAssertionError rather than granting a second token.
Source
Thrown at fastmcp_slim/fastmcp/server/auth/identity_assertion.py:455
else:
claim_matches = normalize_resource_url(
assertion_resource
) == normalize_resource_url(resource_url)
if not claim_matches:
raise IdentityAssertionError(
f"Assertion resource {assertion_resource!r} does not match "
f"this server {resource_url!r}"
)
# 7. jti replay rejection (RFC 7523 §3). Must be a non-empty string —
# an array/object jti is unhashable and would raise TypeError on the
# cache lookup (a 500) instead of a clean invalid_grant.
jti = claims.get("jti")
if not jti or not isinstance(jti, str):
raise IdentityAssertionError("Assertion must include a string jti claim")
cached_exp = self._jti_cache.get(jti)
if cached_exp is not None and cached_exp > now:
raise IdentityAssertionError(f"Assertion replay detected: jti {jti} reused")
# Enforce the cap BEFORE inserting so a rejected assertion never grows the
# cache. A fresh jti that would exceed capacity is rejected outright (after
# a cleanup pass to reclaim any expired entries first).
if (
jti not in self._jti_cache
and len(self._jti_cache) >= self._jti_cache_max_size
):
self._cleanup_expired_jtis()
if len(self._jti_cache) >= self._jti_cache_max_size:
logger.warning("ID-JAG jti cache at capacity, possible attack")
raise IdentityAssertionError("Server overloaded, please retry")
self._jti_cache[jti] = exp
logger.debug("ID-JAG validated for subject=%s issuer=%s", sub, iss)
return claims
View on GitHub (pinned to 1f02114297)
Solutions
- Mint a new assertion with a fresh jti (and fresh exp) for every authentication attempt instead of reusing a cached token
- Check client retry logic so it re-requests/re-signs the assertion rather than resending the same JWT
- If legitimate multi-use is needed, obtain multiple assertions from the issuer — the validator intentionally forbids jti reuse until expiry
- Confirm server clocks are synchronized (NTP); large skew extends the window during which the original jti stays cached
Example fix
// before: reusing one assertion on retry assertion = mint_assertion() # once retry(lambda: validate(assertion)) // after retry(lambda: validate(mint_assertion())) # fresh jti each attempt
Defensive patterns
Strategy: try-catch
Validate before calling
claims = jwt.decode(assertion, options={"verify_signature": False})
# cannot fully pre-check server cache; ensure your client mints a fresh jti per attempt Type guard
def is_fresh_assertion(claims: dict, seen_jtis: set[str]) -> bool:
return isinstance(claims.get("jti"), str) and claims["jti"] not in seen_jtis Try / catch
try:
validate(assertion)
except IdentityAssertionError as e:
if "replay detected" in str(e):
assertion = mint_new_assertion() # fresh jti + exp
validate(assertion)
else:
raise Prevention
- Mint a new assertion (new jti) for every authentication attempt
- Never cache and resend the same signed assertion across retries
- Synchronize server clocks (NTP) to minimize ambiguity windows
When it happens
Trigger: Calling validate() twice with the same assertion JWT (same jti) before its exp passes; an attacker replaying a captured assertion; a client retry that re-sends the identical assertion instead of minting a fresh one; clock skew where the cached exp is still > now.
Common situations: Client-side retry middleware replaying the same signed assertion; load-balanced servers sharing no cache but a client retrying against the same node; scripted tests reusing a pre-generated assertion across runs; a captured token replayed in a security incident.
Related errors
- Assertion replay detected: jti {jti} already used
- Assertion must include exp claim
- Assertion must include sub claim
- Assertion must include a string jti claim
- Authorization failed for tool '{tool_name}': insufficient sc
AI-assisted analysis of PrefectHQ/fastmcp@1f02114297 (2026-08-29).
Data as JSON: /api/errors/a2dd793ec51f619d.
Report an issue: GitHub.