{"record":{"id":"cc7418c9edb1565a","repo":"PrefectHQ/fastmcp","slug":"assertion-resource-assertion-resource-r-does-not","errorCode":null,"errorMessage":"Assertion resource {assertion_resource!r} does not match this server {resource_url!r}","messagePattern":"Assertion resource (.+?) does not match this server (.+?)","errorType":"validation","errorClass":"IdentityAssertionError","httpStatus":null,"severity":"error","filePath":"fastmcp_slim/fastmcp/server/auth/identity_assertion.py","lineNumber":442,"sourceCode":"        if not assertion_client_id or assertion_client_id != client_id:\n            raise IdentityAssertionError(\n                f\"Assertion client_id {assertion_client_id!r} does not match \"\n                f\"authenticated client {client_id!r}\"\n            )\n        if resource_url is not None:\n            assertion_resource = claims.get(\"resource\")\n            if not isinstance(assertion_resource, str) or not assertion_resource:\n                raise IdentityAssertionError(\"Assertion is missing resource claim\")\n            if server_url_has_query(resource_url):\n                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 (","sourceCodeStart":424,"sourceCodeEnd":460,"githubUrl":"https://github.com/PrefectHQ/fastmcp/blob/1f021142978e0861cd910c8df4e8074bc7cf3978/fastmcp_slim/fastmcp/server/auth/identity_assertion.py#L424-L460","documentation":"The assertion carries a `resource` claim but it does not match the resource URL of this MCP server (compared after normalization, or via rstrip('/') when the requested resource URL has a query string). FastMCP requires the signed resource binding to match the server receiving the exchange, preventing an assertion minted for one server from being redeemed at another.","triggerScenarios":"Calling `validate()` with resource_url set and a string `resource` claim that normalizes to a different URL — assertion minted for https://server-a presented at https://server-b; trailing-slash/scheme/port differences that normalization doesn't collapse; localhost vs 127.0.0.1; env-specific base URLs (staging vs prod) baked into the assertion.","commonSituations":"Server URL changed (new domain, added port, HTTPS enforced) while the IdP still stamps the old URL; dev assertion pointed at localhost presented against a deployed server; reverse proxy terminating TLS so the configured server URL differs from the one signed.","solutions":["Update the issuer's resource-indicator config to emit this server's exact public URL.","Restart/re-authenticate so newly minted assertions carry the current server URL.","Compare the two URLs in the error message and reconcile scheme, host, port, and path differences.","Ensure the server's configured base URL (what clients request as resource) matches what the IdP signs."],"exampleFix":"// before\nclaims = {\"resource\": \"https://old.example.com/mcp\", ...}  # presented at https://new.example.com/mcp\n// after\nclaims = {\"resource\": \"https://new.example.com/mcp\", ...}","handlingStrategy":"validation","validationCode":"def resource_matches(claims: dict, server_url: str) -> bool:\n    r = claims.get(\"resource\")\n    return isinstance(r, str) and r.rstrip(\"/\") == server_url.rstrip(\"/\")","typeGuard":"def resource_bound_here(claims: dict, server_url: str) -> bool:\n    r = claims.get(\"resource\")\n    return isinstance(r, str) and r.rstrip(\"/\") != \"\" and r.rstrip(\"/\") == server_url.rstrip(\"/\")","tryCatchPattern":"try:\n    token = await exchange(assertion, resource=SERVER_URL)\nexcept IdentityAssertionError as e:\n    if \"does not match this server\" in str(e):\n        assertion = await obtain_assertion(resource=SERVER_URL)  # re-mint for this server\n        token = await exchange(assertion, resource=SERVER_URL)\n    else:\n        raise","preventionTips":["Keep the IdP's resource-indicator allowlist updated with the server's current public URL.","Use one canonical server base URL everywhere (config, docs, IdP registration).","Request a fresh assertion when the server's URL changes rather than reusing cached ones."],"tags":["auth","oauth","resource-indicators","url-mismatch"],"backgroundTag":"resource-indicator-mismatch","analyzedSha":"1f021142978e0861cd910c8df4e8074bc7cf3978","analyzedAt":"2026-08-29T14:31:16.082Z","schemaVersion":2},"datasetVersion":"2026-08-29T17:17:51.833Z"}