# Which invariants prevent audience confusion in MCP transport-level OAuth enforcement **Date of Record:** 2026 November 13 **Scope:** Applies to MCP servers operating over streamable HTTP or SSE transports that accept OAuth 2.1 bearer tokens and delegate authorization to an upstream authorization server. Does not apply to stdio-local MCP servers where the process boundary itself constitutes the trust boundary, nor to servers that terminate tokens issued and validated entirely within the same process. ## Core Distinction The distinction that matters is between *token validity* and *token applicability*. A JWT can be cryptographically valid, unexpired, correctly signed, and issued by the expected authorization server, and still be entirely inapplicable to the MCP server that received it. Audience binding (RFC 8707 resource indicators, `aud` claim, and per-resource scoped access tokens) is the mechanism that separates these two properties. Most transport-level failures in MCP deployments do not come from accepting forged tokens; they come from accepting *real* tokens minted for a *different* resource. The non-obvious part: MCP servers are frequently deployed as multiplexed endpoints behind a shared authorization server, with multiple MCP resource servers (e.g., a filesystem tool server, a database tool server, a calendar tool server) all trusting the same issuer. The token that a client obtained for the calendar server will validate cleanly at the filesystem server unless the filesystem server enforces `aud` (or the equivalent resource indicator) against its own canonical resource URI. Signature validation alone does not prevent this. The invariant is not "the token is authentic" but "the token was minted for *this* resource." ## What Was Observed During a multi-server MCP deployment, a client negotiated OAuth with a shared authorization server. The authorization server issued an access token with `aud: "https://mcp.example.com/calendar"` after the client passed a resource indicator in the authorization request. The client then opened a second MCP session against `https://mcp.example.com/filesystem`, reusing its cached token. The filesystem server validated the signature against the issuer's JWKS, confirmed `exp` and `nbf`, confirmed the issuer matched its configured trust anchor, and admitted the request. The tool invocation succeeded. The failure was not a signature failure, an expiry failure, or a scope failure in the narrow sense. The token's scope claim was `calendar.read` — but the filesystem server had been configured with a coarse scope check that only verified *presence* of a scope claim, not its value against the requested tool's required scopes. Two independent boundary conditions aligned: missing audience enforcement and missing per-request scope enforcement. Either alone would have blocked the request; together they produced a cross-resource confused-deputy. A second observation: the filesystem server's per-request authorization was implemented as a one-time check at session initialization. The MCP transport allowed the session to persist across token refresh, and the refreshed token carried a different audience. The server never re-validated on subsequent requests within the same session. Session-level authorization became stale authorization. ## Condition of Validity Audience-bound authorization is valid only when all of the following hold simultaneously: 1. The resource server has a **canonical resource URI** that is stable and registered with the authorization server. 2. The client includes that URI as a **resource indicator** (RFC 8707 `resource` parameter) in the authorization request, and the authorization server echoes it into `aud`. 3. The resource server, on **every request**, verifies `aud` equals its canonical URI (exact string match, not prefix or suffix match — `https://mcp.example.com/filesystem` must not accept `aud: "https://mcp.example.com/filesystem-admin"`). 4. The resource server verifies **scope-to-tool binding** per request, not per session, resolving the tool name to a required scope and checking the token's scope set. 5. The resource server treats **session identity as orthogonal to authorization** — a valid session does not imply a valid token for the current request. Condition 3 fails silently when the authorization server is configured with a default audience or when `aud` is omitted and the server falls back to issuer-only trust. Condition 4 fails when the tool registry is dynamic and scope requirements are attached to tools at registration time but not consulted at invocation time. Condition 5 fails whenever the transport layer caches an authorization decision at handshake. ## Decision Boundary The decision boundary is between **fail-closed audience enforcement** and **fail-open issuer-only trust**. The boundary is crossed the moment a deployment treats "signed by a trusted issuer" as sufficient. There is no safe middle ground where audience is checked "sometimes" — partial enforcement is worse than none, because it produces a false sense of security while leaving the confused-deputy path open for any resource whose audience matches the fallback. A second boundary: **per-request vs. per-session authorization**. If the MCP transport permits long-lived sessions (SSE streams, resumable streamable HTTP), authorization must be re-evaluated per request, because the token attached to a session can rotate, expire, or be re-scoped. Caching the authorization decision at session open is a boundary violation even if audience was checked correctly at open time. A third boundary: **exact-match vs. pattern-match audience**. Wildcard or prefix audience matching (`https://mcp.example.com/*`) collapses distinct resources into one audience and reopens the confused-deputy path across every resource under that prefix. The correct rule is exact string equality against the registered resource URI. ## Operational Effect When the invariants hold, the operational effect is that a token minted for resource A is rejected at resource B with a 401 and a `WWW-Authenticate` challenge carrying `error="invalid_token"` and a description naming the audience mismatch. The client is forced to re-authorize against resource B, obtaining a distinct token. The blast radius of any single token compromise is bounded to one resource. When the invariants fail, the operational effect is silent cross-resource access: the filesystem server executes calendar-scoped tokens, audit logs record the request as authenticated (because it was), and the only detectable signal is a scope-to-tool mismatch that a coarse scope check does not surface. Detection requires logging the `aud` claim and the resolved required scope per request and alerting on any request where the token audience does not equal the server's canonical URI, or where the tool's required scope is absent from the token's scope set. Neither alert fires under issuer-only trust because the comparison is never made. The remediation is not a configuration tweak; it is an invariant restoration: register canonical resource URIs, require resource indicators at authorization time, enforce exact-match audience per request, bind scopes to tools at invocation time, and abandon session-level authorization caching for any transport that allows token rotation. ```python import hashlib from dataclasses import dataclass class AudienceBindingError(RuntimeError): """Raised when a bearer token is valid but not applicable to this resource.""" @dataclass(frozen=True) class TokenClaims: issuer: str audience: str scopes: frozenset[str] subject: str expires_at: int @dataclass(frozen=True) class ResourceServer: canonical_uri: str trusted_issuer: str tool_scope_map: dict[str, str] def authorize_request( server: ResourceServer, claims: TokenClaims, tool_name: str, now: int, ) -> None: if claims.issuer != server.trusted_issuer: raise AudienceBindingError( f"issuer mismatch: token={claims.issuer!r} trusted={server.trusted_issuer!r}" ) if claims.expires_at <= now: raise AudienceBindingError( f"token expired at {claims.expires_at}, now={now}" ) # Exact-match audience binding. No prefix, no wildcard, no fallback. if claims.audience != server.canonical_uri: raise AudienceBindingError( "audience mismatch: token minted for " f"{claims.audience!r}, presented to {server.canonical_uri!r}" ) required_scope = server.tool_scope_map.get(tool_name) if required_scope is None: raise AudienceBindingError( f"tool {tool_name!r} has no registered scope binding; refusing to invoke" ) if required_scope not in claims.scopes: raise AudienceBindingError( f"tool {tool_name!r} requires scope {required_scope!r}; " f"token scopes={sorted(claims.scopes)!r}" ) # Log the binding decision for post-hoc audit. Hash subject to avoid PII in logs. digest = hashlib.sha256(claims.subject.encode()).hexdigest()[:12] print( f"authorized subject={digest} tool={tool_name} " f"aud={claims.audience} scope={required_scope}" ) def handle_mcp_request( server: ResourceServer, claims: TokenClaims, tool_name: str, now: int, ) -> str: # Per-request enforcement: never cache this decision at session open. authorize_request(server, claims, tool_name, now) return f"invoked {tool_name}" if __name__ == "__main__": fs_server = ResourceServer( canonical_uri="https://mcp.example.com/filesystem", trusted_issuer="https://auth.example.com", tool_scope_map={"read_file": "filesystem.read", "write_file": "filesystem.write"}, ) calendar_token = TokenClaims( issuer="https://auth.example.com", audience="https://mcp.example.com/calendar", scopes=frozenset({"calendar.read"}), subject="user-42", expires_at=9999999999, ) try: handle_mcp_request(fs_server, calendar_token, "read_file", now=1700000000) except AudienceBindingError as e: print(f"rejected as expected: {e}") ```