{"record":{"id":"7b55593ceace0e4b","repo":"PrefectHQ/fastmcp","slug":"assertion-client-id-assertion-client-id-r-does-n","errorCode":null,"errorMessage":"Assertion client_id {assertion_client_id!r} does not match authenticated client {client_id!r}","messagePattern":"Assertion client_id (.+?) does not match authenticated client (.+?)","errorType":"validation","errorClass":"IdentityAssertionError","httpStatus":null,"severity":"error","filePath":"fastmcp_slim/fastmcp/server/auth/identity_assertion.py","lineNumber":425,"sourceCode":"            raise IdentityAssertionError(\"Assertion must include sub claim\")\n\n        # 5. Required scopes on the issued access token derive from the assertion.\n        if self.config.required_scopes:\n            granted = set(_assertion_scopes(claims))\n            missing = set(self.config.required_scopes) - granted\n            if missing:\n                raise IdentityAssertionError(\n                    f\"Assertion missing required scopes: {sorted(missing)}\"\n                )\n\n        # 6. The signed client_id and resource claims bind the assertion to the\n        # presenting client and this server. Checked here — before jti is\n        # recorded as consumed below — so an assertion presented with the\n        # wrong binding is rejected without burning replay protection for\n        # whichever client/server it actually belongs to.\n        assertion_client_id = claims.get(\"client_id\")\n        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 \"","sourceCodeStart":407,"sourceCodeEnd":443,"githubUrl":"https://github.com/PrefectHQ/fastmcp/blob/1f021142978e0861cd910c8df4e8074bc7cf3978/fastmcp_slim/fastmcp/server/auth/identity_assertion.py#L407-L443","documentation":"The assertion's signed `client_id` claim must match the `client_id` of the client presenting it in the token exchange. FastMCP binds assertions to the presenting client to prevent an assertion minted for one OAuth client from being replayed by another; a mismatch (or absent client_id) raises this error, deliberately before jti replay state is consumed.","triggerScenarios":"Calling `validate()` where `claims[\"client_id\"] != client_id` — client B presents an assertion minted for client A; the assertion lacks client_id entirely; the IdP names the client differently (e.g. issuer uses a different client identifier format) than the one authenticating the exchange.","commonSituations":"Sharing a cached assertion file between two dev clients; proxy fronting the exchange with its own client credentials; IdP claim mapping placing the client identity under a different claim; rotating client credentials so the assertion was minted under the old client_id.","solutions":["Ensure each client requests its own assertion from the IdP rather than reusing another client's.","Configure the IdP to embed the presenting client's exact `client_id` in the assertion (matching the one used in the token exchange).","Clear caches of pre-minted assertions when client credentials change.","Log/compare both client_ids in the message to identify the binding mismatch."],"exampleFix":"// before\n// client-b presents assertion minted with client_id=\"client-a\"\n// after\n// client-b obtains a fresh assertion from the IdP authenticated as \"client-b\"\nassertion_claims[\"client_id\"] == \"client-b\"","handlingStrategy":"validation","validationCode":"def client_id_matches(claims: dict, presenting_client_id: str) -> bool:\n    cid = claims.get(\"client_id\")\n    return isinstance(cid, str) and bool(cid) and cid == presenting_client_id","typeGuard":"def bound_to_client(claims: dict, client_id: str) -> bool:\n    return claims.get(\"client_id\") == client_id","tryCatchPattern":"try:\n    token = await exchange(assertion)\nexcept IdentityAssertionError as e:\n    if \"does not match authenticated client\" in str(e):\n        assertion = await obtain_own_assertion(client_id=MY_CLIENT_ID)\n        token = await exchange(assertion)\n    else:\n        raise","preventionTips":["Never share or cache assertions across different OAuth clients.","Include client_id in the assertion minting request so the IdP binds it correctly.","Invalidate cached assertions whenever client credentials rotate."],"tags":["auth","oauth","client-binding","identity-assertion"],"backgroundTag":"token-client-mismatch","analyzedSha":"1f021142978e0861cd910c8df4e8074bc7cf3978","analyzedAt":"2026-08-29T14:31:16.082Z","schemaVersion":2},"datasetVersion":"2026-08-29T17:17:51.833Z"}