{"record":{"id":"54051545951977ae","repo":"PrefectHQ/fastmcp","slug":"assertion-replay-detected-jti-jti-already-used","errorCode":null,"errorMessage":"Assertion replay detected: jti {jti} already used","messagePattern":"Assertion replay detected: jti (.+?) already used","errorType":"exception","errorClass":"ValueError","httpStatus":null,"severity":"error","filePath":"fastmcp_slim/fastmcp/server/auth/cimd.py","lineNumber":629,"sourceCode":"            if exp > now + self.MAX_ASSERTION_LIFETIME:\n                raise ValueError(\n                    f\"Assertion exp too far in future (max {self.MAX_ASSERTION_LIFETIME}s)\"\n                )\n\n        # 4. Additional RFC 7523 validation: sub claim must equal client_id\n        if claims.get(\"sub\") != client_id:\n            raise ValueError(f\"Assertion sub claim must be {client_id}\")\n\n        # 5. Check jti for replay attacks (RFC 7523 requirement)\n        jti = claims.get(\"jti\")\n        if not jti:\n            raise ValueError(\"Assertion must include jti claim\")\n\n        # Check if JTI was already used (and hasn't expired from cache)\n        if jti in self._jti_cache:\n            cached_exp = self._jti_cache[jti]\n            if cached_exp > now:  # Still valid in cache\n                raise ValueError(f\"Assertion replay detected: jti {jti} already used\")\n            # Expired in cache, can be reused (clean it up)\n            del self._jti_cache[jti]\n\n        # Emergency size limit (shouldn't hit with proper TTL cleanup)\n        if len(self._jti_cache) >= self._jti_cache_max_size:\n            self._cleanup_expired_jtis()\n            # If still over limit after cleanup, reject to prevent DoS\n            if len(self._jti_cache) >= self._jti_cache_max_size:\n                self.logger.warning(\n                    \"JTI cache at max capacity (%d), possible attack\",\n                    self._jti_cache_max_size,\n                )\n                raise ValueError(\"Server overloaded, please retry\")\n\n        # Add to cache with expiration time\n        # Use the assertion's exp claim so it stays cached until it would expire anyway\n        self._jti_cache[jti] = exp\n","sourceCodeStart":611,"sourceCodeEnd":647,"githubUrl":"https://github.com/PrefectHQ/fastmcp/blob/1f021142978e0861cd910c8df4e8074bc7cf3978/fastmcp_slim/fastmcp/server/auth/cimd.py#L611-L647","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["Mint a fresh assertion (new jti, new iat/exp) for every request, including retries","Fix deterministic jti generation — use uuid4 or a random component per mint","Ensure retry middleware re-invokes the token-minting callback, not the cached body"],"exampleFix":"// before\nassertion = build_jwt(jti=\"fixed-id\")  # reused every request\n// after\nassertion = build_jwt(jti=str(uuid.uuid4()))  # fresh per request","handlingStrategy":"validation","validationCode":"import time, uuid\n# Re-mint whenever the cached assertion is near expiry or already sent once\ndef get_fresh_assertion(client_id, minted_state={\"jti\": None, \"exp\": 0}):\n    if time.time() > minted_state[\"exp\"] - 5:\n        minted_state[\"jti\"] = str(uuid.uuid4())\n        minted_state[\"exp\"] = time.time() + 300\n    return sign_assertion(client_id, jti=minted_state[\"jti\"])","typeGuard":"def is_unsent(jti: str, sent_jtis: set) -> bool:\n    return jti not in sent_jtis","tryCatchPattern":"try:\n    validator.validate_assertion(token, client_id, jwks)\nexcept ValueError as e:\n    if \"replay detected\" in str(e):\n        token = mint_fresh_assertion(client_id)  # never resend the same jti\n        validator.validate_assertion(token, client_id, jwks)\n    else:\n        raise","preventionTips":["Mint a new assertion (new jti) for every request AND every retry","Disable retry middleware reuse of the same serialized body","Use uuid4, not deterministic hashes, for jti","Never replay test fixture assertions across runs within their validity window"],"tags":["oauth","jwt","replay-attack","security"],"backgroundTag":"jwt-replay-detected","analyzedSha":"1f021142978e0861cd910c8df4e8074bc7cf3978","analyzedAt":"2026-08-29T14:31:16.082Z","schemaVersion":2},"datasetVersion":"2026-08-29T17:17:51.833Z"}