PrefectHQ/fastmcp · error · IdentityAssertionError
Assertion resource does not match this server
Error message
Assertion resource {assertion_resource!r} does not match this server {resource_url!r} What it means
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.
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.
Example fix
// before
claims = {"resource": "https://old.example.com/mcp", ...} # presented at https://new.example.com/mcp
// after
claims = {"resource": "https://new.example.com/mcp", ...} Defensive patterns
Strategy: validation
Validate before calling
def resource_matches(claims: dict, server_url: str) -> bool:
r = claims.get("resource")
return isinstance(r, str) and r.rstrip("/") == server_url.rstrip("/") Type guard
def resource_bound_here(claims: dict, server_url: str) -> bool:
r = claims.get("resource")
return isinstance(r, str) and r.rstrip("/") != "" and r.rstrip("/") == server_url.rstrip("/") Try / catch
try:
token = await exchange(assertion, resource=SERVER_URL)
except IdentityAssertionError as e:
if "does not match this server" in str(e):
assertion = await obtain_assertion(resource=SERVER_URL) # re-mint for this server
token = await exchange(assertion, resource=SERVER_URL)
else:
raise Prevention
- 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.
When it happens
Trigger: 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.
Common situations: 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.
Related errors
- Assertion is missing resource claim
- Assertion client_id does not match authenticated client
- Assertion missing required scopes
- Assertion must include a string jti claim
- Assertion must include exp claim
AI-assisted analysis of PrefectHQ/fastmcp@1f02114297 (2026-08-29).
Data as JSON: /api/errors/cc7418c9edb1565a.
Report an issue: GitHub.
Appendix: source
Thrown at fastmcp_slim/fastmcp/server/auth/identity_assertion.py:442
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 "
f"this server {resource_url!r}"
)
# 7. jti replay rejection (RFC 7523 §3). Must be a non-empty string —
# an array/object jti is unhashable and would raise TypeError on the
# cache lookup (a 500) instead of a clean invalid_grant.
jti = claims.get("jti")
if not jti or not isinstance(jti, str):
raise IdentityAssertionError("Assertion must include a string jti claim")
cached_exp = self._jti_cache.get(jti)
if cached_exp is not None and cached_exp > now:
raise IdentityAssertionError(f"Assertion replay detected: jti {jti} reused")
# Enforce the cap BEFORE inserting so a rejected assertion never grows the
# cache. A fresh jti that would exceed capacity is rejected outright (after
# a cleanup pass to reclaim any expired entries first).
if (View on GitHub (pinned to 1f02114297)