PrefectHQ/fastmcp · error · IdentityAssertionError

Assertion client_id {assertion_client_id!r} does not match a

Error message

Assertion client_id {assertion_client_id!r} does not match authenticated client {client_id!r}

What it means

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.

Source

Thrown at fastmcp_slim/fastmcp/server/auth/identity_assertion.py:425

            raise IdentityAssertionError("Assertion must include sub claim")

        # 5. Required scopes on the issued access token derive from the assertion.
        if self.config.required_scopes:
            granted = set(_assertion_scopes(claims))
            missing = set(self.config.required_scopes) - granted
            if missing:
                raise IdentityAssertionError(
                    f"Assertion missing required scopes: {sorted(missing)}"
                )

        # 6. The signed client_id and resource claims bind the assertion to the
        # presenting client and this server. Checked here — before jti is
        # recorded as consumed below — so an assertion presented with the
        # wrong binding is rejected without burning replay protection for
        # whichever client/server it actually belongs to.
        assertion_client_id = claims.get("client_id")
        if not assertion_client_id or assertion_client_id != client_id:
            raise IdentityAssertionError(
                f"Assertion client_id {assertion_client_id!r} does not match "
                f"authenticated client {client_id!r}"
            )
        if resource_url is not None:
            assertion_resource = claims.get("resource")
            if not isinstance(assertion_resource, str) or not assertion_resource:
                raise IdentityAssertionError("Assertion is missing resource claim")
            if server_url_has_query(resource_url):
                claim_matches = assertion_resource.rstrip("/") == resource_url.rstrip(
                    "/"
                )
            else:
                claim_matches = normalize_resource_url(
                    assertion_resource
                ) == normalize_resource_url(resource_url)
            if not claim_matches:
                raise IdentityAssertionError(
                    f"Assertion resource {assertion_resource!r} does not match "

View on GitHub (pinned to 1f02114297)

Solutions

  1. Ensure each client requests its own assertion from the IdP rather than reusing another client's.
  2. Configure the IdP to embed the presenting client's exact `client_id` in the assertion (matching the one used in the token exchange).
  3. Clear caches of pre-minted assertions when client credentials change.
  4. Log/compare both client_ids in the message to identify the binding mismatch.

Example fix

// before
// client-b presents assertion minted with client_id="client-a"
// after
// client-b obtains a fresh assertion from the IdP authenticated as "client-b"
assertion_claims["client_id"] == "client-b"
Defensive patterns

Strategy: validation

Validate before calling

def client_id_matches(claims: dict, presenting_client_id: str) -> bool:
    cid = claims.get("client_id")
    return isinstance(cid, str) and bool(cid) and cid == presenting_client_id

Type guard

def bound_to_client(claims: dict, client_id: str) -> bool:
    return claims.get("client_id") == client_id

Try / catch

try:
    token = await exchange(assertion)
except IdentityAssertionError as e:
    if "does not match authenticated client" in str(e):
        assertion = await obtain_own_assertion(client_id=MY_CLIENT_ID)
        token = await exchange(assertion)
    else:
        raise

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Understand the failure class

Related errors


AI-assisted analysis of PrefectHQ/fastmcp@1f02114297 (2026-08-29). Data as JSON: /api/errors/7b55593ceace0e4b. Report an issue: GitHub.