MCP Server Security — Server-Sent Events
MCP server Server-Sent Events reconnect security — Last-Event-ID credentials, SSE auth re-validation, and newline injection
Server-Sent Events (SSE) is the primary streaming mechanism in many MCP server implementations — the MCP SDK uses SSE to deliver streaming tool results, progress updates, and multi-part responses back to the Claude client. SSE's reconnect behavior introduces three security issues that HTTP request/response doesn't have: the Last-Event-ID header (used to resume after disconnect) is often treated as a session identifier and not validated against the authenticated user; authentication is only checked at the initial connection, not on auto-reconnect (so a token that expires mid-stream serves data on reconnect without re-auth); and event data containing newlines can inject new SSE fields that alter how the client interprets the stream. This page covers all three with mitigations.
Attack 1: Last-Event-ID as session credential — insecure resume allows cross-user event replay
The SSE protocol specifies that when a client reconnects after a disconnect, it sends the Last-Event-ID header containing the ID of the last event it received. The server uses this to replay any events the client missed. MCP servers that use sequential integers or UUIDs as event IDs and store events in a shared buffer (Redis list, database table) per-session allow an attacker who guesses or enumerates a valid event ID from another user's session to replay that session's events — effectively reading another user's tool call results.
// INSECURE: sequential integer event IDs allow enumeration
let eventId = 0;
function sendEvent(res, data) {
eventId++;
res.write(`id: ${eventId}\n`); // WRONG: guessable integer
res.write(`data: ${JSON.stringify(data)}\n\n`);
}
// A different user connecting with Last-Event-ID: 142 can receive events 143+
// from any session that has buffered those events globally.
// -----------------------------------------------------------------------
// SECURE: HMAC-signed event IDs scoped to the authenticated user and session
import crypto from 'node:crypto';
function createEventId(userId, sessionId, seq) {
const payload = `${userId}:${sessionId}:${seq}`;
const sig = crypto
.createHmac('sha256', process.env.EVENT_ID_SECRET)
.update(payload)
.digest('base64url');
// Encode as base64url — opaque to client, verifiable by server
return Buffer.from(`${payload}|${sig}`).toString('base64url');
}
function verifyEventId(rawId, expectedUserId, expectedSessionId) {
try {
const decoded = Buffer.from(rawId, 'base64url').toString('utf8');
const [payload, sig] = decoded.split('|');
const [userId, sessionId, seqStr] = payload.split(':');
// Verify userId and sessionId match the authenticated connection
if (userId !== expectedUserId || sessionId !== expectedSessionId) {
throw new Error('Event ID belongs to different user/session');
}
// Verify HMAC
const expectedSig = crypto
.createHmac('sha256', process.env.EVENT_ID_SECRET)
.update(payload)
.digest('base64url');
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig))) {
throw new Error('Event ID HMAC invalid');
}
return { userId, sessionId, seq: parseInt(seqStr, 10) };
} catch {
throw new Error('Invalid Last-Event-ID');
}
}
// In SSE reconnect handler:
app.get('/sse/events', async (req, res) => {
const user = await authenticate(req);
if (!user) return res.status(401).end();
const lastEventIdHeader = req.headers['last-event-id'];
let resumeFromSeq = 0;
if (lastEventIdHeader) {
try {
const { seq } = verifyEventId(lastEventIdHeader, user.id, req.query.sessionId);
resumeFromSeq = seq + 1;
} catch (err) {
return res.status(400).json({ error: err.message });
}
}
// Set up SSE
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
// Replay buffered events from resumeFromSeq for this specific user+session
const buffered = await getBufferedEvents(user.id, req.query.sessionId, resumeFromSeq);
for (const event of buffered) {
res.write(`id: ${event.id}\ndata: ${event.data}\n\n`);
}
// Continue streaming new events...
});
Attack 2: Missing auth re-validation on SSE reconnect — expired tokens serve stale data
SSE connections are persistent HTTP responses. The client automatically reconnects when the connection drops — this is a built-in browser and fetch API behavior. Authentication is typically checked when the connection is first established. But JWT access tokens have short lifetimes (15 minutes to 1 hour). If a user's token expires while their SSE connection is established, the connection stays alive (the JWT is not re-checked on an already-open connection). When the connection drops and the client reconnects, the expired token in the request headers must be rejected — but many SSE implementations cache the authentication result from the initial connection and don't re-validate on reconnect.
// SSE connection with periodic auth re-validation
// The connection object tracks auth expiry; flushes and closes when token expires
class SseConnection {
constructor(res, user, tokenExpiresAt) {
this.res = res;
this.user = user;
this.tokenExpiresAt = tokenExpiresAt;
this.closed = false;
// Check token expiry every 30 seconds
this.authCheckInterval = setInterval(() => this.checkAuthExpiry(), 30_000);
res.on('close', () => this.cleanup());
}
checkAuthExpiry() {
if (Date.now() >= this.tokenExpiresAt * 1000) {
// Send error event before closing — client should re-auth and reconnect
this.send('auth_expired', { message: 'Token expired. Re-authenticate to continue.' });
this.close(401);
}
}
send(event, data) {
if (this.closed) return;
const safeData = sanitizeEventData(JSON.stringify(data));
this.res.write(`event: ${event}\ndata: ${safeData}\n\n`);
}
close(statusHint) {
if (this.closed) return;
this.closed = true;
clearInterval(this.authCheckInterval);
// SSE doesn't have a close frame — send a terminal event
this.res.write(`event: close\ndata: ${JSON.stringify({ reason: 'auth_expired' })}\n\n`);
this.res.end();
}
cleanup() {
this.closed = true;
clearInterval(this.authCheckInterval);
}
}
// On reconnect, always re-validate the token from the new request headers
app.get('/sse/stream', async (req, res) => {
const token = req.headers.authorization?.replace(/^Bearer\s+/, '');
if (!token) return res.status(401).end();
let claims;
try {
claims = await verifyJwt(token); // throws if expired
} catch {
return res.status(401).json({ error: 'Token expired or invalid. Re-authenticate.' });
}
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
const conn = new SseConnection(res, claims.sub, claims.exp);
// Register and use conn for streaming...
});
Attack 3: Newline injection in event data — injecting synthetic SSE fields
The SSE wire format uses newlines as field separators: each field is a key: value\n line, and a blank line (\n\n) terminates the event. If event data contains a literal newline character, the SSE parser sees a field boundary and starts a new field. An attacker who can control content that ends up in SSE event data — tool results that include user-supplied content, fetched web pages, database records — can inject synthetic event: or data: fields that alter how the client parses subsequent events, potentially suppressing real events or injecting fake ones.
// Newline sanitizer for SSE event data
// Must be applied to ALL dynamic content before writing to the event stream
function sanitizeEventData(rawData) {
if (typeof rawData !== 'string') rawData = JSON.stringify(rawData);
// Replace all newline variants with their JSON-safe equivalents
// Within a JSON string, \n and \r are already escaped — but if rawData is
// not a JSON-encoded string (e.g., raw text from a tool result), sanitize directly.
// The safe approach: JSON-encode all event data, so newlines become \\n in the wire format
// and the SSE parser never sees a bare newline within a data field.
// If rawData is already a JSON string:
if (rawData.startsWith('{') || rawData.startsWith('[') || rawData.startsWith('"')) {
// JSON.parse + JSON.stringify ensures all control characters are escaped
return JSON.stringify(JSON.parse(rawData));
}
// For raw text, escape newlines explicitly:
return rawData
.replace(/\r\n/g, '\\r\\n') // CRLF
.replace(/\n/g, '\\n') // LF
.replace(/\r/g, '\\r'); // CR
}
// USAGE — wrap all event data writes:
function sendSseEvent(res, eventType, data) {
// eventType must not contain newlines (it's developer-controlled, but validate anyway)
const safeEventType = eventType.replace(/[\r\n]/g, '');
// data must have newlines escaped before writing
const safeData = sanitizeEventData(typeof data === 'string' ? data : JSON.stringify(data));
res.write(`event: ${safeEventType}\n`);
res.write(`data: ${safeData}\n\n`);
}
// Example: tool result with user-controlled content
server.tool('fetch_page', async (args) => {
const pageContent = await fetchUrl(args.url); // could contain \n\n
// This is safe because sendSseEvent sanitizes newlines
sendSseEvent(sseConnection.res, 'tool_result', { content: pageContent });
});
SkillAudit findings
The following findings appear in SkillAudit audit reports for MCP servers using Server-Sent Events:
CRITICAL Last-Event-ID is a sequential integer — cross-user event stream access via enumeration. SSE event IDs are globally incrementing integers. A client that sends Last-Event-ID: N can receive events from any user's session if the event buffer is not partitioned by authenticated user. Use HMAC-signed, user-scoped event IDs.
HIGH Auth not re-validated on SSE reconnect — expired tokens receive buffered data. Authentication is verified on initial connection establishment but not on reconnect. Clients presenting expired JWTs receive buffered events from their previous session. Re-validate the token on every new connection, including auto-reconnects.
HIGH Tool result content written to SSE stream without newline sanitization. Tool results containing user-controlled or fetched content are written directly to the SSE event stream without escaping newline characters. Content containing \n\n terminates the current SSE event early and may inject synthetic fields, corrupting the event stream or enabling event injection.
MEDIUM No per-user SSE connection limit — connection exhaustion possible. The SSE endpoint accepts unlimited concurrent connections per user. Each SSE connection holds a server socket and a Redis pub/sub subscription. An attacker with valid credentials can exhaust server resources by opening thousands of concurrent SSE streams.
MEDIUM No token refresh mechanism on long-lived SSE connections. SSE connections are kept alive longer than the JWT access token TTL with no mechanism for the client to present a refreshed token mid-stream. The server either keeps serving an expired-token session indefinitely or closes the connection, requiring a full reconnect. Implement a token refresh SSE event type that the client responds to with a new token.
Paste a GitHub URL at skillaudit.dev to get a graded report card.