Securing the Streamable HTTP Transport for MCP Servers

Most writing about MCP security stops at the token. That is understandable, because authorization is where the specification has the most to say and where reviewers start. But a remote MCP server is an HTTP service with long-lived streams and a session identifier, and the transport carries its own set of findings. We have seen servers with a correct OAuth 2.1 implementation fail on transport questions alone.

This is what to get right in the Streamable HTTP transport, and what a security reviewer will ask about each item.

A session ID is not a credential

The Streamable HTTP transport lets a server assign a session identifier in the Mcp-Session-Id response header at initialization, which the client then returns on subsequent requests. The specification is explicit that this identifier is for session management. It is not an authentication mechanism, and the server must still authorize every request on its own merits.

The failure mode is a server that validates the token once during initialization, records the session, and afterwards trusts the session ID. From that point the session ID is a bearer credential in everything but name: it sits in a header, it does not expire on its own, and nothing binds it to the person who created it.

What to do instead:

  • Require and validate the access token on every HTTP request, including requests that carry a session ID and including the GET that opens a stream.
  • Bind the session to the subject and tenant from the token at creation, and reject any request whose token subject does not match the session's subject. A stolen session ID is then useless without the matching token.
  • Generate the identifier with a cryptographically secure random source, at least 128 bits, and treat it as globally unique and non-guessable. Do not encode a user ID, tenant ID, or sequence number in it.
  • Give sessions a maximum lifetime and an idle timeout, and honour the client's DELETE for explicit termination. Return 404 for an unknown or expired session so the client knows to re-initialize.

Reviewers ask this as: "What stops one of your customers' users from replaying another user's session?"

Origin validation and DNS rebinding

Any MCP server reachable over HTTP must validate the Origin header on incoming requests. This is not decoration. Without it, a page in a user's browser can issue requests to the server, and for a locally bound server the browser will happily attach whatever ambient credentials exist. DNS rebinding turns a hostname the attacker controls into a request against 127.0.0.1, which is why a local server that binds 0.0.0.0 and skips Origin checks is remotely reachable in practice.

The rules are short:

  • Validate Origin against an allow-list of expected values and reject anything else with 403. Do not reflect the received origin back in Access-Control-Allow-Origin.
  • Servers intended for local use bind to 127.0.0.1, not 0.0.0.0.
  • Return CORS headers deliberately. If browser-based clients are not a supported use case, do not send permissive CORS headers at all.
  • Do not accept the access token from a query parameter or a cookie. Tokens belong in the Authorization header, where they are not written to proxy logs, browser history, or Referer.

Streams outlive decisions

The transport allows a server to hold an open SSE stream and push messages to the client over minutes or hours. Every authorization decision made when that stream opened decays while it is open. The token expires. An administrator removes the user from a group. The customer disables the account. Security teams ask about exactly this, because it is the difference between revocation on paper and revocation in effect.

A workable design:

  • Record the token's expiry when the stream opens and close the stream at that point. The client can re-authorize and reconnect.
  • Re-check the authorization context before each server-initiated message that carries data, not only at connection time.
  • Subscribe to revocation signals from the identity provider where they exist, and terminate matching sessions and streams when one arrives.
  • Cap concurrent streams per user and per client application, and cap total stream lifetime regardless of token expiry.

Resumption is a replay surface

Streamable HTTP supports resumption: the server attaches an event ID to messages, and a disconnected client reconnects with Last-Event-ID to pick up where it left off. The specification requires that these IDs be unique per stream within a session. Treat them as an authorization surface as well.

  • Scope event IDs to the session and the stream, and confirm on resumption that the requesting session and token subject own that stream. Otherwise a guessed or leaked event ID replays another user's messages.
  • Make event IDs random rather than sequential.
  • Bound the replay buffer by size and age. An unbounded buffer of undelivered tool results is both a memory issue and a data-retention question you will be asked to answer.
  • Delete buffered events when the session is terminated.

Headers, methods, and the boring checks

These cost little and come up in review:

  • Validate Accept and Content-Type strictly. Reject request bodies that are not JSON.
  • Enforce a maximum request body size and a maximum message size. Agents generate large payloads; a server that will parse an unbounded body is a denial-of-service target.
  • Rate limit per user, per session, and per tool, not only per IP. One agent loop can produce a call volume no human ever would.
  • Set a timeout on every tool handler and return a structured error rather than holding the connection.
  • Send Cache-Control: no-store on responses that contain tenant data, and no Server banner detail.
  • Terminate TLS in front of the server, and require TLS everywhere, including inside the client's network if their policy asks for it.

Behind a proxy or gateway

Most enterprise deployments put the server behind a load balancer, an API gateway, or an MCP gateway. Two things break there.

First, SSE and buffering proxies do not agree. Response buffering must be off for the stream path, or messages arrive in batches and idle timeouts kill connections that are working correctly. Set the idle timeout on the proxy above the server's own stream lifetime, and disable buffering for that route explicitly.

Second, trust boundaries. If a gateway validates the token and forwards identity in a header, the server must reject that header when the request did not come from the gateway. Bind that trust with mutual TLS or a signed header, and network-isolate the server so it cannot be reached directly. A server that trusts X-Forwarded-User from anyone is a one-line cross-tenant vulnerability, and it is the first thing we test on a governed platform engagement.

What to write down

The evidence a reviewer wants for the transport is smaller than for authorization, and it is easy to prepare in advance:

  • A one-page description of the session model: how identifiers are generated, what they are bound to, when they expire, how they are terminated.
  • The Origin allow-list and the CORS policy, with the deployment that uses each.
  • Stream lifetime and revocation behaviour, stated as a maximum in minutes.
  • The limits: body size, message size, concurrent streams, rate limits.
  • The results of the tests: a replayed session ID from a second user's token, a cross-origin request, a Last-Event-ID from another session, an oversized body, a call flood. Record what the server returned.

None of this is difficult. It is simply a layer that gets skipped because the demo worked over stdio on a laptop and nobody revisited the assumptions when the server moved to a URL. Our security audits cover the transport alongside the authorization flow, because in practice both show up in the same questionnaire.