{"record":{"id":"5539ff998cba533a","repo":"PrefectHQ/fastmcp","slug":"assertion-must-include-a-string-jti-claim","errorCode":null,"errorMessage":"Assertion must include a string jti claim","messagePattern":"Assertion must include a string jti claim","errorType":"validation","errorClass":"IdentityAssertionError","httpStatus":null,"severity":"error","filePath":"fastmcp_slim/fastmcp/server/auth/identity_assertion.py","lineNumber":452,"sourceCode":"                claim_matches = assertion_resource.rstrip(\"/\") == resource_url.rstrip(\n                    \"/\"\n                )\n            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)","sourceCodeStart":434,"sourceCodeEnd":470,"githubUrl":"https://github.com/PrefectHQ/fastmcp/blob/1f021142978e0861cd910c8df4e8074bc7cf3978/fastmcp_slim/fastmcp/server/auth/identity_assertion.py#L434-L470","documentation":"This error is raised by IdentityAssertion.validate() when validating an RFC 7523 identity/ID-JAG assertion whose JWT claims lack a usable 'jti' (JWT ID) claim. The jti must be a non-empty string because it is used as the key in the replay-detection cache; an array or object jti would be unhashable and crash the cache lookup with a TypeError/500 instead of a clean invalid_grant rejection. The library requires it per RFC 7523 §3.","triggerScenarios":"Calling validate() on an assertion JWT whose claims have no 'jti', an empty-string jti (falsy), or a non-string jti such as a list or dict (claims.get('jti') fails the `not jti or not isinstance(jti, str)` check at identity_assertion.py:452).","commonSituations":"Token minting code omits jti because it is optional in some JWT profiles; a custom issuer serializes jti as an integer or object; an IdP template leaves the jti field empty; hand-crafted test tokens forget the claim.","solutions":["Add a unique, non-empty string 'jti' claim (e.g. a UUID) to every assertion JWT at mint time","Verify the issuer does not serialize jti as a number/array/object — cast to str(uuid4()) before signing","Decode the assertion locally (jwt.decode without verification) to inspect the actual claims before submitting","If you don't need replay protection semantics, still supply jti — it is mandatory in this validator"],"exampleFix":"// before: payload built without jti\npayload = {\"iss\": iss, \"sub\": sub, \"aud\": aud, \"exp\": exp}\n// after\npayload = {\"iss\": iss, \"sub\": sub, \"aud\": aud, \"exp\": exp, \"jti\": str(uuid.uuid4())}","handlingStrategy":"validation","validationCode":"claims = jwt.decode(assertion, options={\"verify_signature\": False})\njti = claims.get(\"jti\")\nif not isinstance(jti, str) or not jti:\n    raise ValueError(\"Assertion is missing a string jti claim; mint a new one\")","typeGuard":"def has_valid_jti(claims: dict) -> bool:\n    jti = claims.get(\"jti\")\n    return isinstance(jti, str) and bool(jti)","tryCatchPattern":null,"preventionTips":["Always include a UUID-based string jti in assertion JWTs at mint time","Never serialize jti as int, list, or object","Decode and sanity-check assertion claims in integration tests before submitting"],"tags":["auth","oauth","jwt","identity-assertion"],"backgroundTag":"missing-jwt-claim","analyzedSha":"1f021142978e0861cd910c8df4e8074bc7cf3978","analyzedAt":"2026-08-29T14:31:16.082Z","schemaVersion":2},"datasetVersion":"2026-08-29T17:17:51.833Z"}