{"record":{"id":"a2dd793ec51f619d","repo":"PrefectHQ/fastmcp","slug":"assertion-replay-detected-jti-jti-reused","errorCode":null,"errorMessage":"Assertion replay detected: jti {jti} reused","messagePattern":"Assertion replay detected: jti (.+?) reused","errorType":"validation","errorClass":"IdentityAssertionError","httpStatus":null,"severity":"error","filePath":"fastmcp_slim/fastmcp/server/auth/identity_assertion.py","lineNumber":455,"sourceCode":"            else:\n                claim_matches = normalize_resource_url(\n                    assertion_resource\n                ) == normalize_resource_url(resource_url)\n            if not claim_matches:\n                raise IdentityAssertionError(\n                    f\"Assertion resource {assertion_resource!r} does not match \"\n                    f\"this server {resource_url!r}\"\n                )\n\n        # 7. jti replay rejection (RFC 7523 §3). Must be a non-empty string —\n        # an array/object jti is unhashable and would raise TypeError on the\n        # cache lookup (a 500) instead of a clean invalid_grant.\n        jti = claims.get(\"jti\")\n        if not jti or not isinstance(jti, str):\n            raise IdentityAssertionError(\"Assertion must include a string jti claim\")\n        cached_exp = self._jti_cache.get(jti)\n        if cached_exp is not None and cached_exp > now:\n            raise IdentityAssertionError(f\"Assertion replay detected: jti {jti} reused\")\n\n        # Enforce the cap BEFORE inserting so a rejected assertion never grows the\n        # cache. A fresh jti that would exceed capacity is rejected outright (after\n        # a cleanup pass to reclaim any expired entries first).\n        if (\n            jti not in self._jti_cache\n            and len(self._jti_cache) >= self._jti_cache_max_size\n        ):\n            self._cleanup_expired_jtis()\n            if len(self._jti_cache) >= self._jti_cache_max_size:\n                logger.warning(\"ID-JAG jti cache at capacity, possible attack\")\n                raise IdentityAssertionError(\"Server overloaded, please retry\")\n        self._jti_cache[jti] = exp\n\n        logger.debug(\"ID-JAG validated for subject=%s issuer=%s\", sub, iss)\n        return claims\n\n","sourceCodeStart":437,"sourceCodeEnd":473,"githubUrl":"https://github.com/PrefectHQ/fastmcp/blob/1f021142978e0861cd910c8df4e8074bc7cf3978/fastmcp_slim/fastmcp/server/auth/identity_assertion.py#L437-L473","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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"],"exampleFix":"// before: reusing one assertion on retry\nassertion = mint_assertion()  # once\nretry(lambda: validate(assertion))\n// after\nretry(lambda: validate(mint_assertion()))  # fresh jti each attempt","handlingStrategy":"try-catch","validationCode":"claims = jwt.decode(assertion, options={\"verify_signature\": False})\n# cannot fully pre-check server cache; ensure your client mints a fresh jti per attempt","typeGuard":"def is_fresh_assertion(claims: dict, seen_jtis: set[str]) -> bool:\n    return isinstance(claims.get(\"jti\"), str) and claims[\"jti\"] not in seen_jtis","tryCatchPattern":"try:\n    validate(assertion)\nexcept IdentityAssertionError as e:\n    if \"replay detected\" in str(e):\n        assertion = mint_new_assertion()  # fresh jti + exp\n        validate(assertion)\n    else:\n        raise","preventionTips":["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"],"tags":["auth","oauth","replay-attack","jwt"],"backgroundTag":"jwt-replay-detected","analyzedSha":"1f021142978e0861cd910c8df4e8074bc7cf3978","analyzedAt":"2026-08-29T14:31:16.082Z","schemaVersion":2},"datasetVersion":"2026-08-29T17:17:51.833Z"}