MCP Server Security · Server-Sent Events · SSE Transport

MCP server event stream security — EventSource SSE authentication, CORS for server-sent events, SSE flood DoS defense, reconnect timing oracle, and MCP SSE transport security

Server-Sent Events (SSE) is one of the two primary MCP transports (alongside Streamable HTTP). The browser's EventSource API opens a persistent unidirectional connection that the server pushes events on — ideal for streaming tool results and agent progress updates. But the SSE transport has a fundamental authentication limitation: EventSource cannot send custom HTTP headers. This forces authentication through query parameters (tokens in URLs, which appear in server logs and browser history) or cookies (which require secure cookie configuration). Beyond auth, SSE connections are long-lived and stateful — flood attacks open thousands of connections to exhaust server file descriptors, and reconnect timing oracles leak whether specific stream IDs exist on the server.

The EventSource authentication problem

Standard Bearer token authentication puts the token in the Authorization HTTP header. The browser EventSource API does not support custom headers — it only sends cookies and the browser's built-in headers on the initial SSE request. This means the two viable authentication options for SSE-based MCP transports are:

MethodHow it worksSecurity tradeoff
Token in URL query parameternew EventSource('/mcp/stream?token=eyJ...')Token appears in server access logs, browser history, Referer headers of subsequent requests, and network proxy logs. Short-lived tokens (≤60s TTL, single-use) reduce the exposure window.
HttpOnly session cookieBrowser sends cookie automatically on all requests to the origin. SSE server validates the session cookie.Requires CORS handling if SSE endpoint is cross-origin. Vulnerable to CSRF unless the session cookie uses SameSite=Strict or the SSE endpoint validates an Origin header allowlist. Not compatible with API-key-authenticated MCP clients (non-browser).
Token exchange before EventSourceFetch a short-lived SSE ticket via a standard authenticated POST, then use the ticket as the URL token.Ticket is single-use and short-lived — minimizes log exposure. The ticket exchange uses a normal fetch() with Authorization header. Best for browser clients.
// CORRECT: ticket exchange pattern — the only token that appears in SSE URL
// is short-lived (30s TTL), single-use, and scoped to this SSE stream

// Step 1: exchange the session JWT for a short-lived SSE ticket
const ticketResponse = await fetch('/mcp/sse-ticket', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${sessionJwt}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ streamScope: 'tool-results' }),
});
const { ticket, expiresAt } = await ticketResponse.json();
// ticket is a random 256-bit token, expires in 30s, single-use, stored in Redis

// Step 2: open the SSE connection with the short-lived ticket
const es = new EventSource(`/mcp/stream?ticket=${ticket}`);

// Server-side: validate ticket on first connection
app.get('/mcp/stream', async (req, res) => {
  const { ticket } = req.query;
  const session = await redis.getdel(`sse-ticket:${ticket}`);  // atomic get+delete = single-use
  if (!session || Date.now() > session.expiresAt) {
    return res.status(401).json({ error: 'Invalid or expired SSE ticket' });
  }
  // Proceed with SSE stream setup
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('X-Accel-Buffering', 'no');
  // ...
});

Never use long-lived session JWTs as SSE URL parameters. Server access logs are often retained for 30–90 days and may be shipped to third-party log aggregators. A long-lived JWT in the URL is effectively a persistent credential in your log pipeline. Use single-use SSE tickets with ≤60-second TTL — even if an attacker reads a ticket from a log, it has already expired and been deleted from the Redis store.

CORS for SSE endpoints

If the SSE endpoint is on a different origin from the MCP UI, the browser applies CORS to the EventSource request. The SSE response must include Access-Control-Allow-Origin with the UI's origin (or * for non-credentialed SSE streams). If the SSE stream carries tenant-specific data, it must not use Access-Control-Allow-Origin: * — use an explicit allowlist instead:

// SSE CORS middleware
const SSE_ALLOWED_ORIGINS = new Set([
  'https://app.skillaudit.dev',
  'https://skillaudit.dev',
]);

app.get('/mcp/stream', (req, res, next) => {
  const origin = req.headers.origin;
  if (origin && SSE_ALLOWED_ORIGINS.has(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Vary', 'Origin');
  }
  // Note: EventSource uses GET — no preflight OPTIONS needed for simple requests
  // but add origin check to prevent cross-origin SSE subscription without CORS grant
  next();
});

SSE flood DoS: exhausting server connections

Each SSE connection holds an open HTTP connection and a server-side event emitter for the lifetime of the stream. A flood attack opens thousands of SSE connections simultaneously — exhausting the server's file descriptor limit (default 1024 on Linux without tuning), thread pool, or memory.

// Connection rate limiting and per-IP connection cap for SSE endpoints
import { RateLimiterMemory } from 'rate-limiter-flexible';

const sseConnectionLimiter = new RateLimiterMemory({
  points: 5,      // max 5 concurrent SSE connections per IP
  duration: 60,   // per 60-second window (connections closed within 60s reset the counter)
});

const sseNewConnLimiter = new RateLimiterMemory({
  points: 10,     // max 10 new SSE connections per IP per minute
  duration: 60,
});

app.get('/mcp/stream', async (req, res, next) => {
  const ip = req.ip;
  try {
    await sseNewConnLimiter.consume(ip);
  } catch {
    res.status(429).set('Retry-After', '60').json({ error: 'Too many SSE connections' });
    return;
  }

  // Track concurrent connections
  const concurrentKey = `sse-concurrent:${ip}`;
  const current = await redis.incr(concurrentKey);
  await redis.expire(concurrentKey, 3600);
  if (current > 5) {
    await redis.decr(concurrentKey);
    res.status(429).json({ error: 'Too many concurrent SSE connections from this IP' });
    return;
  }

  // Decrement on disconnect
  res.on('close', async () => {
    await redis.decr(concurrentKey);
  });

  next();
});

Reconnect timing oracle

The SSE protocol includes automatic reconnection: when a connection closes, the browser waits for the server-specified retry: interval and then reconnects. An attacker can probe the reconnect timing to determine whether a specific stream ID exists on the server:

The timing difference between "stream exists, expired session" vs. "stream does not exist" leaks whether a stream ID is valid. Constant-time responses close this oracle:

// Anti-timing: always take the same time regardless of whether stream ID exists
app.get('/mcp/stream/:streamId', async (req, res) => {
  const { streamId } = req.params;
  const ticket = req.query.ticket;

  // Validate ticket first — always takes ~10ms (bcrypt/scrypt timing safe)
  const session = ticket ? await validateSseTicket(ticket) : null;

  // Stream lookup — regardless of session validity
  const streamExists = await redis.exists(`stream:${streamId}`);

  if (!session || !streamExists) {
    // Same 401 response whether stream doesn't exist or session is invalid
    // Prevents timing oracle on stream existence
    res.status(401).json({ error: 'Unauthorized' });
    return;
  }

  // Proceed with SSE
});

SkillAudit findings for SSE/EventSource security in MCP servers

CRITICAL −22Long-lived session JWT or API key passed as SSE URL query parameter — token appears in server access logs, nginx access logs, CDN logs, and Referer headers of any subsequent cross-origin sub-requests from the SSE page; effectively a persistent credential in the log pipeline
HIGH −18No rate limiting on SSE connection establishment — attacker opens thousands of concurrent SSE connections to exhaust server file descriptors, memory, or thread pool; no per-IP connection cap or new-connection rate limit enforced
HIGH −16SSE endpoint returns tenant-specific data with Access-Control-Allow-Origin: * — any cross-origin page can subscribe to the SSE stream and read the tenant's tool results without authentication (for wildcard-accessible streams) or using the victim's cookies (if wildcard is on a credentialed endpoint)
HIGH −14SSE endpoint authenticates via session cookie without SameSite=Strict or Origin header validation — cross-origin EventSource subscription automatically sends the session cookie; a CSRF-equivalent attack subscribes to the victim's event stream and receives their real-time tool results
MEDIUM −12SSE ticket is multi-use or has TTL > 60 seconds — if the URL appears in a log or proxy, the ticket remains usable for a meaningful window; attacker with log access can replay the SSE connection with the victim's ticket
MEDIUM −8SSE connection rejection timing differs based on whether stream ID exists vs. session invalid — reconnect timing oracle allows enumeration of valid stream IDs without authenticated access; constant-time rejection required

See also: WebSocket auth security · CORS credential security · CSRF security

Run a free SkillAudit on your MCP server →