# Which invariants prevent agent identity binding from silently degrading under MCP session reuse **Date of Record:** 2026 November 12 **Scope:** Applies to Model Context Protocol (MCP) deployments where a long-lived transport session (stdio, SSE, or streamable HTTP) carries an authenticated agent identity, and where the authorization decision for tool calls is derived from that session-bound identity rather than re-verified per request. Does not apply to fully stateless MCP transports that re-authenticate and re-authorize on every JSON-RPC message, nor to single-tenant local stdio servers with no identity layer at all. ## Core Distinction There are two different things that can be "bound" to an MCP session, and conflating them is the source of the failure mode: 1. **Session binding** — the transport-level association between a connection (or `Mcp-Session-Id`) and a server-side session object that holds negotiated capabilities, subscriptions, and pending request state. 2. **Identity binding** — the association between that session object and an authenticated principal (agent ID, credential, and the scope set the principal was granted at authentication time). The non-obvious invariant is this: **identity binding must be re-proven on every authorization decision, not merely established once at session creation.** A session that was created by agent A and is now being driven by a connection controlled by agent B is still, structurally, a valid session. Nothing in the transport layer notices. The session ID is opaque and bearer-like; whoever presents it gets the session's accumulated authority. Continuous proof-of-possession (PoP) is what keeps identity binding from decaying into session binding. ## What Was Observed An MCP server using streamable HTTP issued an `Mcp-Session-Id` header at `initialize` and accepted that header on subsequent requests. The agent's OAuth token was validated at `initialize` and the resulting principal was cached on the session object. Tool-call authorization read `session.principal.scopes`. Two things were observed in production telemetry: - Session IDs appeared in server access logs (a debug middleware logged request headers), and a session ID captured from a log replay was accepted by the server from a different client process. The replaying client did not possess the original agent's token; it only held the session ID. Every tool call succeeded with the original agent's scopes. - Separately, a legitimate agent that rotated its credential mid-session continued to operate under its *old* scope set for the remainder of the session, because scopes were snapshotted at `initialize` and never refreshed. A scope revocation did not take effect until the session was torn down. Both are the same invariant violation viewed from opposite directions: the session's cached identity was treated as authoritative without proof that the current connection still corresponds to that identity, and without re-evaluating that identity against current policy. A subtler variant: because MCP sessions can be resumed (`Last-Event-ID` on SSE resumption), a client that reconnects with a valid session ID and a stale event cursor can re-enter a session whose principal has since been deprovisioned. The server sees a well-formed resume, not an intrusion. ## Condition of Validity The invariant holds — identity binding does not silently degrade — only when all of the following are true: 1. **The session ID is not a bearer credential.** Presenting `Mcp-Session-Id` alone must be insufficient to act. Each request must carry a proof-of-possession artifact (a DPoP-style signed JWT bound to a client-held key, or an mTLS client cert) that the server verifies against the key registered at authentication time. 2. **The PoP key is bound to the principal, not the session.** If the key is generated at session creation and stored on the session object, an attacker who obtains the session object also obtains the key reference. The key must be resolvable from the principal's registered credentials. 3. **Authorization is re-evaluated per tool call against current policy**, using the principal derived from the *current request's* PoP, not a scope snapshot stored at `initialize`. Scope sets are policy inputs, not session state. 4. **Session resumption re-runs the PoP check.** A resume is an authorization event, not a state-restore event. 5. **Session ID entropy is sufficient and IDs are never logged or placed in URLs.** This is necessary but not sufficient — it reduces hijack probability but does not satisfy the invariant. If any of 1–4 is false, the system is in a state where a valid-looking session can carry the wrong identity, and no error is raised. ## Decision Boundary The decision boundary is whether to treat a session as an *identity-bearing capability* or as a *state container keyed by identity*. - **Treat as identity-bearing capability** (the failure mode): the session ID grants authority. This is acceptable only when the session is short-lived, single-use, and the ID never leaves the authenticated channel — conditions that do not hold for MCP sessions that support resumption and long-running tool workflows. - **Treat as state container** (the correct model): the session holds negotiated state; every request independently proves possession of the principal's key and re-derives authorization. The session ID is a lookup key, not a grant. The boundary is crossed the moment the server makes an authorization decision using identity that was not proven by the current request. Detection of the crossing requires comparing, per request, the principal proven by PoP against the principal recorded on the session; a mismatch is a hijack signal and must fail closed, not fall back to the session's recorded principal. ## Operational Effect With the invariant enforced, three operational properties follow: - **Hijack detection becomes possible.** A request presenting session S with a PoP for principal B, where S is bound to principal A, is a detectable event. Log it as a security event with both principals and the session ID hash; do not silently serve it. - **Credential rotation and revocation take effect at the next request**, not at session teardown. Long-running agent sessions no longer outlive the authority that created them. - **Resumption is safe.** A resumed session re-proves possession and re-authorizes; a deprovisioned principal cannot re-enter via a stale event cursor. The cost is one signature verification and one policy evaluation per tool call. In practice this is sub-millisecond against local policy caches and is the price of not having a silent privilege-escalation path that the transport layer cannot see. ```python import hashlib import hmac import time from dataclasses import dataclass, field from typing import Optional class SessionIdentityMismatch(RuntimeError): """Raised when a request's proven principal does not match the session's bound principal.""" class ProofOfPossessionError(RuntimeError): """Raised when the per-request proof-of-possession artifact is missing or invalid.""" @dataclass class Principal: agent_id: str public_key: bytes scopes: frozenset @dataclass class Session: session_id: str bound_principal_id: str created_at: float # NOTE: no scopes cached here on purpose; authorization is re-derived per request. def _verify_pop(principal: Principal, request_body: bytes, signature: bytes) -> bool: # DPoP-style: signature over the request body with the principal's registered key. expected = hmac.new(principal.public_key, request_body, hashlib.sha256).digest() return hmac.compare_digest(expected, signature) def authorize_tool_call( sessions: dict, principals: dict, session_id: Optional[str], request_body: bytes, signature: Optional[bytes], required_scope: str, now: Optional[float] = None, ) -> Principal: now = now if now is not None else time.time() if session_id is None or signature is None: raise ProofOfPossessionError("missing session id or proof-of-possession artifact") session = sessions.get(session_id) if session is None: raise SessionIdentityMismatch(f"unknown session {session_id!r}") # Resolve principal from the PoP key, not from the session object. proven_principal: Optional[Principal] = None for p in principals.values(): if _verify_pop(p, request_body, signature): proven_principal = p break if proven_principal is None: raise ProofOfPossessionError("proof-of-possession did not verify against any registered key") # Continuous binding check: the proven principal must be the one the session was bound to. if proven_principal.agent_id != session.bound_principal_id: raise SessionIdentityMismatch( f"session {session_id!r} bound to {session.bound_principal_id!r} " f"but request proven by {proven_principal.agent_id!r}" ) # Re-evaluate authorization against current policy, not a snapshot. if required_scope not in proven_principal.scopes: raise SessionIdentityMismatch( f"principal {proven_principal.agent_id!r} lacks scope {required_scope!r} at {now:.0f}" ) return proven_principal ```