Topic: mcp server SSE event stream security
MCP server SSE (Server-Sent Events) security — auth on stream connect, reconnect token validation, event injection
Server-Sent Events (SSE) is one of the primary streaming transports for MCP servers that push tool results, progress notifications, or real-time data to LLM agents. SSE has a distinct security surface from request-response APIs: authentication must happen before the stream is established (not on the first event), reconnection tokens can be replayed if not validated and expired, event data must be escaped to prevent injection of fake events, and long-lived connections create auth drift as credentials expire mid-stream.
1. Authenticate at connection time, not after
SSE connections begin with an HTTP GET to the event stream URL. The server responds with Content-Type: text/event-stream and keeps the connection open. The critical mistake is accepting the connection first and validating credentials only on the first event — this creates a window where an unauthenticated connection is open and consuming server resources:
// VULNERABLE: connection accepted before auth is validated
app.get("/events", (req, res) => {
// SSE headers set immediately — connection is open before any auth check
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
// Auth check happens AFTER the connection is open — too late
// Unauthenticated clients now hold open a server-side connection
const token = req.headers.authorization?.split(" ")[1];
if (!validateToken(token)) {
res.write("event: error\ndata: Unauthorized\n\n");
res.end(); // Connection was already open for a brief window
return;
}
// Push events to the connected client...
});
// SECURE: validate auth before sending SSE headers
app.get("/events", async (req, res) => {
// Validate authentication before establishing the SSE connection
const token = req.headers.authorization?.split(" ")[1];
if (!token) {
res.status(401).json({ error: "Authorization header required" });
return;
}
const session = await validateToken(token);
if (!session) {
res.status(401).json({ error: "Invalid or expired token" });
return;
}
// Only establish SSE after auth is confirmed
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no"); // Disable nginx buffering
// Attach authenticated session to the response for use in event handlers
const ctx = { userId: session.userId, orgId: session.orgId };
// Send initial connection confirmation with session-scoped data
res.write(`event: connected\ndata: ${JSON.stringify({ userId: ctx.userId })}\n\n`);
// Set up event handlers using ctx for authorization...
const cleanup = subscribeToEvents(ctx, (event) => {
res.write(`event: ${event.type}\ndata: ${safeEventData(event.data)}\n\n`);
});
req.on("close", () => cleanup());
});
2. Reconnect token validation — Last-Event-ID attacks
SSE clients reconnect automatically after disconnection, sending the Last-Event-ID header to resume from where they left off. If the server uses Last-Event-ID as a token to re-authenticate the reconnection without validating it, an attacker can replay an expired or stolen token to resume another user's event stream:
// VULNERABLE: trusts Last-Event-ID as a reconnect auth token without validation
app.get("/events", async (req, res) => {
const lastEventId = req.headers["last-event-id"];
if (lastEventId) {
// VULNERABLE: assumes Last-Event-ID is a valid session token
// No expiry check, no binding to the current HTTP session, no HMAC validation
const session = await db.sessions.findById(lastEventId);
if (session) {
// Resume stream for this session — but attacker can replay any session ID
startSSEStream(res, session);
return;
}
}
// Initial connection requires auth
const token = req.headers.authorization?.split(" ")[1];
// ...
});
// SECURE: Last-Event-ID contains only the event sequence number
// Re-authentication uses the same Authorization header as the initial connection
app.get("/events", async (req, res) => {
// Always require Authorization header — both initial and reconnect requests
const token = req.headers.authorization?.split(" ")[1];
if (!token) { res.status(401).json({ error: "Unauthorized" }); return; }
const session = await validateToken(token);
if (!session) { res.status(401).json({ error: "Invalid token" }); return; }
// Last-Event-ID is an integer event sequence number, not a session token
const lastSeqStr = req.headers["last-event-id"];
const lastSeq = lastSeqStr ? parseInt(lastSeqStr, 10) : 0;
if (isNaN(lastSeq) || lastSeq < 0) {
res.status(400).json({ error: "Invalid Last-Event-ID" });
return;
}
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
// Replay missed events since lastSeq for this authenticated session
const missedEvents = await db.events.findAfterSeq(session.userId, lastSeq);
for (const event of missedEvents) {
// Include id: field so client can track sequence number
res.write(`id: ${event.seq}\nevent: ${event.type}\ndata: ${safeEventData(event.data)}\n\n`);
}
const cleanup = subscribeToEvents(session, (event) => {
res.write(`id: ${event.seq}\nevent: ${event.type}\ndata: ${safeEventData(event.data)}\n\n`);
});
req.on("close", () => cleanup());
});
3. Event injection via unescaped newlines in data
The SSE protocol uses newlines as event field delimiters. A data value that contains a raw newline followed by event:, data:, or id: can inject fake events into the stream. If tool result data flows directly into SSE output without sanitization, an attacker who can control tool result content can inject events that look like they came from the server:
// SSE wire format — newlines are structural:
// event: tool_result
// data: {"result": "success"}
//
// If data contains a raw newline:
// data: {"result": "first line\nevent: injected\ndata: {\"type\":\"auth_expired\"}\n"}
//
// The browser SSE parser sees TWO events:
// Event 1: tool_result data: {"result": "first line"}
// Event 2: injected data: {"type":"auth_expired"}
// The attacker injected a fake server event
// VULNERABLE: data written to SSE without newline escaping
app.get("/events", async (req, res) => {
// ...auth setup...
subscribeToToolResults(session, (result) => {
// If result.content contains newlines, this creates injection
res.write(`event: tool_result\ndata: ${JSON.stringify(result)}\n\n`);
});
});
// JSON.stringify escapes newlines to \n (not raw newlines) — JSON is SSE-safe
// But if data is a raw string (not JSON), newlines must be escaped
// SECURE: escape helper for non-JSON data fields
function safeEventData(data: unknown): string {
const str = typeof data === "string" ? data : JSON.stringify(data);
// SSE spec: data field may span multiple lines using multiple "data:" prefixes
// But any raw newline must be handled — safest approach is to escape them
// For JSON output: JSON.stringify already escapes \n, \r — safe by default
// For raw strings: replace newlines with their SSE continuation pattern
return str
.replace(/\r\n/g, "\\r\\n") // Windows newlines
.replace(/\r/g, "\\r") // CR-only (old Mac)
.replace(/\n/g, "\\n"); // Unix newlines
}
// Alternative: use JSON.stringify for all event data
// JSON.stringify encodes \n as the two-character string \n (not a raw newline)
// This is the simplest and safest approach for structured event data
function writeEvent(res: Response, type: string, data: unknown): void {
const payload = JSON.stringify(data);
// JSON.stringify guarantees no raw newlines in the output
res.write(`event: ${type}\ndata: ${payload}\n\n`);
}
// Also sanitize the event type field — it must not contain newlines
function sanitizeEventType(type: string): string {
return type.replace(/[\r\n]/g, "");
}
4. Auth drift — credentials expire mid-stream
SSE connections are long-lived. A JWT issued at connection time expires after 15 minutes, but the SSE connection stays open for hours. Events pushed after the JWT expiry carry data that the caller is no longer authorized to receive:
// SECURE: re-validate credentials periodically during long SSE streams
class AuthenticatedSSEConnection {
private sessionId: string;
private tokenExpiry: number;
private revalidateInterval: NodeJS.Timeout | null = null;
constructor(
private res: Response,
private session: AuthSession,
private cleanup: () => void,
) {
this.sessionId = session.sessionId;
this.tokenExpiry = session.expiresAt;
this.startRevalidation();
}
private startRevalidation(): void {
// Re-validate every 5 minutes — well within a 15-minute JWT TTL
this.revalidateInterval = setInterval(async () => {
const isValid = await validateSessionStillActive(this.sessionId);
if (!isValid) {
this.res.write("event: auth_expired\ndata: {\"code\":\"SESSION_EXPIRED\"}\n\n");
this.terminate();
}
}, 5 * 60 * 1000);
// Also close the stream when the original token expires
const msUntilExpiry = this.tokenExpiry - Date.now();
if (msUntilExpiry > 0) {
setTimeout(() => {
this.res.write("event: auth_expired\ndata: {\"code\":\"TOKEN_EXPIRED\"}\n\n");
this.terminate();
}, msUntilExpiry);
}
}
terminate(): void {
if (this.revalidateInterval) clearInterval(this.revalidateInterval);
this.cleanup();
this.res.end();
}
}
// Maximum stream duration regardless of token validity
const MAX_STREAM_DURATION_MS = 4 * 60 * 60 * 1000; // 4 hours
app.get("/events", async (req, res) => {
// ...auth validation...
const connection = new AuthenticatedSSEConnection(res, session, cleanup);
// Hard cap on stream duration to prevent forgotten open connections
const maxDurationTimeout = setTimeout(() => {
res.write("event: stream_timeout\ndata: {\"code\":\"MAX_DURATION_REACHED\"}\n\n");
connection.terminate();
}, MAX_STREAM_DURATION_MS);
req.on("close", () => {
clearTimeout(maxDurationTimeout);
connection.terminate();
});
});
5. Connection limit DoS — open connection exhaustion
SSE connections are long-lived HTTP connections. Without a per-caller connection limit, an attacker can open thousands of connections from different IP addresses or with different tokens to exhaust the server's file descriptor limit or memory:
// SECURE: track and limit active SSE connections per user
const activeConnections = new Map<string, Set<string>>(); // userId → Set of connectionIds
const MAX_CONNECTIONS_PER_USER = 5;
const MAX_TOTAL_CONNECTIONS = 10_000;
app.get("/events", async (req, res) => {
// ...auth validation...
const { userId } = session;
// Enforce per-user connection limit
if (!activeConnections.has(userId)) {
activeConnections.set(userId, new Set());
}
const userConnections = activeConnections.get(userId)!;
if (userConnections.size >= MAX_CONNECTIONS_PER_USER) {
res.status(429).json({
error: `Maximum concurrent SSE connections (${MAX_CONNECTIONS_PER_USER}) reached for this account`
});
return;
}
// Enforce global connection limit
const totalConnections = [...activeConnections.values()].reduce(
(sum, set) => sum + set.size, 0
);
if (totalConnections >= MAX_TOTAL_CONNECTIONS) {
res.status(503).json({ error: "Server at capacity" });
return;
}
const connectionId = crypto.randomUUID();
userConnections.add(connectionId);
req.on("close", () => {
userConnections.delete(connectionId);
if (userConnections.size === 0) activeConnections.delete(userId);
});
// ...establish stream...
});
SkillAudit findings for SSE MCP servers
Run a SkillAudit scan to detect SSE auth patterns, reconnect token handling, and event data escaping automatically.