# In what scenarios must an instance reject MCP requests whose OAuth 2.1 token audience matches the resource indicator but whose transport-level session binding has been silently re-established across a reconnecting client **Date of Record:** 2026 November 14 **Scope:** Applies to MCP servers exposing the Streamable HTTP transport (single `/mcp` endpoint, POST + GET SSE upgrade, optional `Mcp-Session-Id` header) that validate OAuth 2.1 access tokens carrying an RFC 8707 `resource` indicator. Does not apply to stdio transports, to stateless "no-session" MCP configurations, or to pure bearer-token gateways that terminate before the MCP layer. ## Core Distinction The OAuth 2.1 resource indicator (`resource` parameter, RFC 8707) binds a token to a *resource server identity* — a URI such as `https://mcp.example.com/mcp`. The MCP Streamable HTTP transport binds a *client session* to a server-issued `Mcp-Session-Id` that is created at `initialize` and is expected to be presented on every subsequent request. These are two independent binding layers, and the failure mode lives in the gap between them. The non-obvious invariant: **audience validation is a necessary but not sufficient authorization check for a stateful MCP request.** A token whose `aud`/`resource` claim equals the server's canonical MCP URI authorizes the *caller* to talk to *this server*. It does **not** authorize that caller to attach to an *existing session*, and it does **not** authorize the server to silently mint a fresh session for a request that presented a stale or unknown `Mcp-Session-Id`. The boundary condition is this: when a reconnecting client presents a valid, correctly-audienced token together with a `Mcp-Session-Id` the server no longer recognizes (evicted, expired, or lost to a restart), the spec-compliant default behavior for the *transport* is to return HTTP 404 and force re-initialization. The dangerous implementation shortcut is to treat the 404 path as recoverable — to accept the request, create a new session, and proceed — because the token validated. That shortcut converts an audience check into an implicit session-creation grant, and it is invisible in logs that only record `auth: ok`. ## What Was Observed In a deployment behind a load balancer with per-node in-memory session tables, a client reconnect after a rolling restart produced the following sequence: 1. Client holds token `T` with `resource=https://mcp.example.com/mcp`, `exp` in 40 minutes, `sub=svc-agent-7`. 2. Restart evicts all `Mcp-Session-Id` entries on the node the client reconnects to. 3. Client POSTs `tools/call` with `Authorization: Bearer T` and `Mcp-Session-Id: 9f3c...` (now unknown). 4. Server validates `T` against the resource indicator — passes — then, because the handler was written as "if session missing, initialize a new one and retry," it creates session `a71e...` and executes the tool call. 5. Client never learns its session changed; it continues sending `9f3c...`, which now maps to nothing, and every subsequent request silently re-forks a new session. Tool calls that assume per-session state (cursor pagination, `roots/list` negotiation, subscription streams) fail intermittently with no auth error. The tell is a metric pattern, not an exception: `auth_valid_total` is flat and healthy while `sessions_created_total` climbs at roughly the request rate, and `sse_streams_open` never exceeds 1 per client. No 401, no 403 — the failure is entirely in the 2xx path. A second observed variant: two clients sharing a rotated token (same `sub`, same `resource`) where the first evicted the second's session on reconnect. Because the token is valid and the resource matches, a naive server lets client B's new session coexist with client A's, and any tool keyed on `sub` rather than `Mcp-Session-Id` cross-contaminates state. ## Condition of Validity The strict behavior (reject with 404, force `initialize`) is correct and required when **all** of the following hold: - The transport is stateful (server issues `Mcp-Session-Id`). - The presented `Mcp-Session-Id` is absent from the server's authoritative session store. - The request is not itself an `initialize` request. The permissive "re-initialize transparently" behavior is only defensible when the server is explicitly configured stateless (no session header emitted), or when a documented, client-visible handshake exists (e.g., a `Mcp-Session-Id` response header on the retry that the client is contractually obliged to adopt). Absent that contract, silent re-initialization is a correctness bug, not a convenience. Note the asymmetry that makes this non-obvious: the OAuth layer's instinct is "valid token ⇒ proceed." The transport layer's instinct is "unknown session ⇒ 404." Both are locally correct. The bug appears only when one layer's success is used to override the other layer's failure, and it is most likely exactly when the token is *most* valid — freshly minted, correctly audienced, unexpired — because that is when no other check fires to catch it. ## Decision Boundary Before executing any stateful MCP method, the server must evaluate two independent predicates and require **both**: - `audience_ok`: token `resource`/`aud` matches the server's canonical MCP URI (exact string match after normalization; do not accept prefix or path-suffix matches). - `session_ok`: presented `Mcp-Session-Id` resolves in the store **and** the session's bound principal matches the token `sub` (or `client_id`). If `audience_ok` and not `session_ok` and method ≠ `initialize` → reject 404 with `Mcp-Session-Id`-unknown semantics; do **not** mint a session. If not `audience_ok` → reject 401/403 regardless of session state. If both → proceed. If method = `initialize` with a stale session header → treat as fresh initialize, issue new session, and require the client to echo it. The boundary is *not* "is the token valid." It is "does this token's holder own this session." Conflating them is the failure. ## Operational Effect Consequences of getting this wrong, ranked by blast radius: - **Silent state forking:** per-session tool state (pagination cursors, negotiated capabilities, active SSE subscriptions) fragments across orphaned sessions; failures surface as intermittent, non-reproducible tool errors. - **Cross-principal contamination:** when session identity is keyed on `sub` instead of `Mcp-Session-Id`, two clients under one rotated token share and corrupt state. - **Audit blind spot:** logs show `auth: ok` and HTTP 200, so incident review attributes failures to the tool implementation rather than the transport/authorization seam. - **Resource leak:** orphaned sessions and their SSE streams accumulate until the node OOMs; the leak rate equals the reconnect rate, which spikes precisely during rolling restarts — i.e., the failure is self-amplifying under the conditions that trigger it. Mitigation: emit a distinct metric for `session_reinit_rejected_total` and alert on `sessions_created_total / authenticated_requests_total > 0.05`. Treat any nonzero transparent-reinit rate as a P2. ```python class SessionAudienceMismatchError(RuntimeError): """Raised when a request's OAuth resource indicator is valid but its transport-level Mcp-Session-Id binding cannot be honored. This is deliberately distinct from 401/403: the caller is authorized to reach this server, but is NOT authorized to attach to (or silently fork) the session implied by the request. """ def __init__(self, session_id: str | None, principal: str, reason: str): self.session_id = session_id self.principal = principal self.reason = reason super().__init__( f"session binding rejected for principal={principal!r} " f"session={session_id!r}: {reason}" ) def authorize_stateful_request( *, token_resource: str, server_canonical_uri: str, token_principal: str, presented_session_id: str | None, session_store: dict[str, dict], # session_id -> {"principal": ...} method: str, ) -> dict: # Predicate 1: audience / resource indicator (RFC 8707). if token_resource != server_canonical_uri: raise SessionAudienceMismatchError( presented_session_id, token_principal, f"resource {token_resource!r} != server {server_canonical_uri!r}", ) # initialize is always allowed to mint a fresh session. if method == "initialize": return {"action": "new_session"} # Predicate 2: transport session binding. A valid token does NOT # authorize silent re-initialization. if presented_session_id is None: raise SessionAudienceMismatchError( None, token_principal, "stateful method without Mcp-Session-Id", ) session = session_store.get(presented_session_id) if session is None: # The dangerous shortcut lives here: do NOT create a new session # just because the token validated. raise SessionAudienceMismatchError( presented_session_id, token_principal, "unknown/evicted Mcp-Session-Id; client must re-initialize", ) if session.get("principal") != token_principal: raise SessionAudienceMismatchError( presented_session_id, token_principal, "session bound to a different principal", ) return {"action": "proceed", "session": session} ```