PrefectHQ/fastmcp · error · ValueError

Assertion replay detected: jti {jti} already used

Error message

Assertion replay detected: jti {jti} already used

What it means

Raised by validate_assertion when the assertion's jti is found in the validator's replay cache and its cached exp is still in the future — meaning this exact assertion (same JWT ID) was already accepted and is being replayed. The server rejects duplicates until the cached entry expires.

Source

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

            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,
                )
                raise ValueError("Server overloaded, please retry")

        # Add to cache with expiration time
        # Use the assertion's exp claim so it stays cached until it would expire anyway
        self._jti_cache[jti] = exp

View on GitHub (pinned to 1f02114297)

Solutions

  1. Mint a fresh assertion (new jti, new iat/exp) for every request, including retries
  2. Fix deterministic jti generation — use uuid4 or a random component per mint
  3. Ensure retry middleware re-invokes the token-minting callback, not the cached body

Example fix

// before
assertion = build_jwt(jti="fixed-id")  # reused every request
// after
assertion = build_jwt(jti=str(uuid.uuid4()))  # fresh per request
Defensive patterns

Strategy: validation

Validate before calling

import time, uuid
# Re-mint whenever the cached assertion is near expiry or already sent once
def get_fresh_assertion(client_id, minted_state={"jti": None, "exp": 0}):
    if time.time() > minted_state["exp"] - 5:
        minted_state["jti"] = str(uuid.uuid4())
        minted_state["exp"] = time.time() + 300
    return sign_assertion(client_id, jti=minted_state["jti"])

Type guard

def is_unsent(jti: str, sent_jtis: set) -> bool:
    return jti not in sent_jtis

Try / catch

try:
    validator.validate_assertion(token, client_id, jwks)
except ValueError as e:
    if "replay detected" in str(e):
        token = mint_fresh_assertion(client_id)  # never resend the same jti
        validator.validate_assertion(token, client_id, jwks)
    else:
        raise

Prevention

When it happens

Trigger: Retrying an HTTP token request with the same serialized assertion after a timeout; a client bug that mints jti deterministically (e.g. fixed string or hash of client_id) producing collisions; intercepting/replaying a captured assertion within its validity window.

Common situations: HTTP clients auto-retrying failed requests without re-minting the JWT; load balancers replaying a request body; test suites reusing a fixture token across test cases.

Related errors


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