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

  1. Mint a new assertion with a fresh jti (and fresh exp) for every authentication attempt instead of reusing a cached token
  2. Check client retry logic so it re-requests/re-signs the assertion rather than resending the same JWT
  3. If legitimate multi-use is needed, obtain multiple assertions from the issuer — the validator intentionally forbids jti reuse until expiry
  4. 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

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


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