Streamable HTTP gave remote MCP servers a session: an Mcp-Session-Id header the server hands back after initialize, which the client then sends on every request after that so the server knows which conversation it’s continuing. That header is a bearer value — whoever presents it gets treated as that session, no other proof required — and a server that doesn’t treat it with the same care as a password has handed out a second, quieter credential right next to the OAuth token it probably did lock down.
What the session ID actually is
When a client opens a session, the server can return an Mcp-Session-Id value in the response to initialize. From then on, the client includes that same header on every subsequent POST and GET to the server’s endpoint, and the server uses it to look up whatever state it’s keeping for that client — which tools got negotiated, what capabilities are in play, and on a server that layers its own logic on top, often far more than that. It’s a session token in every practical sense, just carried in an HTTP header the spec defined for MCP specifically rather than a cookie or an Authorization header.
The spec is explicit that this ID is not an authentication mechanism on its own: it identifies a session, and a server is expected to still authenticate the request through whatever OAuth or API key scheme it already uses, the way OAuth discovery and OAuth scopes cover. The gap this post is about opens when an implementation treats the session ID as if it were enough by itself — valid header in, request served, no further check against who actually owns that session.
How a session ID actually leaks
None of these require breaking cryptography. They’re the same handful of ways any bearer token ends up somewhere it shouldn’t:
- Predictable values. A session ID generated from a counter, a timestamp, or anything else guessable doesn’t need to be stolen — it can be computed. The spec’s guidance is that session IDs should be generated with a secure random source, the same bar you’d hold any session token to.
- Logged in the wrong place. Headers end up in request logs, proxy access logs, and error-reporting tools more often than request bodies do, because nobody thinks of a header as “data” the way they think of a payload. A session ID sitting next to a timestamp in a log line anyone on the team can read is a leak that never involved an attacker doing anything active.
- Not re-checked against the original client. If a server accepts any request bearing a known session ID without also confirming it still matches the connection, user, or token that opened that session, anyone who obtains the ID — however they got it — can resume the session as if they were the original caller.
That last point is the one that matters most, because it’s a design choice, not a leak. A session ID that leaked once but can’t be reused on its own is a much smaller problem than one that can.
Why this is a quieter version of a familiar problem
It’s worth placing this next to two risks already covered here. Session hijacking shares its shape with DNS rebinding on a local server: both let a party that was never issued real access reach a server by presenting something that looks like proof of legitimacy but isn’t — a familiar-looking origin in one case, a valid-looking header in the other. And it echoes the confused deputy problem: a server that honors a session ID without re-verifying who it belongs to is, in miniature, a server that trusts a credential more than it trusts the identity behind it.
What makes it quieter than most of the risks in this series is that there’s no obvious tell. A hijacked session doesn’t look malformed, doesn’t fail a schema check, and doesn’t show up as an error in anyone’s log — it looks exactly like the same client continuing the same conversation, because as far as the protocol is concerned, that’s exactly what it is.
What actually closes it
The fix is ordinary session-management hygiene, applied to a header most implementers don’t think to treat that way:
- Generate session IDs with a cryptographically secure random source — long enough, and unpredictable enough, that guessing one isn’t a realistic path in.
- Bind the session to the identity that opened it (the authenticated user or client) and re-check that binding on every request, not just once at
initialize. - Keep session IDs out of URLs and query strings, where they end up in server access logs and browser history by default, and treat them like any other secret in application logs — redacted, not just “it’s only an ID.”
- Expire sessions, and don’t accept an
Mcp-Session-Idfor a session the server has already closed or never issued.
If you’re building a server, this is squarely implementation-layer work the MCP SDKs don’t fully do for you — worth checking explicitly with the MCP Inspector and the kind of pre-publish pass covered in testing an MCP server before you publish it.
What this means if you’re only connecting servers
As someone deciding whether to connect a remote server rather than build one, you mostly can’t audit this directly — session handling lives entirely on the server side, same as the vetting gap covered in how to vet an MCP server. The practical version of this risk, for you, is about blast radius: the fewer servers that hold a live session with real account access, the less any one session-handling mistake on a vendor’s side can cost you, and that’s the same reasoning behind connecting read-only servers first laid out in which MCP servers to connect first.
The bottom line
Mcp-Session-Id is a bearer value doing a session token’s job, and the spec says as much — it’s not meant to replace authentication, only to track which conversation a request belongs to. The risk isn’t in the header existing; it’s in a server that forgets the difference and lets a valid-looking ID stand in for a verified identity. If you’re building a server, generate it properly, bind it, and log it like you would any other secret. If you’re only connecting one, this is one more reason the servers holding real account access deserve more scrutiny than the ones that don’t.