MCP server Streamable HTTP transport security
The MCP 2025-03-26 specification replaced the original HTTP+SSE dual-endpoint transport with a unified “Streamable HTTP” transport. A single endpoint accepts both POST (JSON-RPC requests) and GET (SSE stream open). The new transport introduces Mcp-Session-Id session tokens, optional SSE streaming on POST responses, and explicit session lifecycle management — each adding distinct attack surface. Four SA-MCP-SHT patterns cover the complete Streamable HTTP security audit.
Streamable HTTP works as follows: the client sends an HTTP POST to the MCP endpoint with Content-Type: application/json. The server may respond with Content-Type: application/json (single response) or Content-Type: text/event-stream (server opens an SSE stream for the duration of the request). Separately, the client may send GET to the same endpoint to open a persistent SSE stream for server-initiated notifications. Sessions are identified by the Mcp-Session-Id header, which the server sets on the initialize response and the client includes on all subsequent requests.
The security surface differs fundamentally from stdio transport: Streamable HTTP servers are HTTP servers, subject to CORS policies, cookie-based authentication, DNS rebinding, and all standard web security concerns that do not apply to stdio. Community MCP server implementations frequently misconfigure one or more of these controls because the server is expected to run on localhost and authors assume isolation guarantees that do not hold.
SA-MCP-SHT-001: Mcp-Session-Id predictability and session hijacking
The Mcp-Session-Id header is specified as an opaque token; the spec requires it to be globally unique and recommends cryptographic randomness but does not mandate a minimum entropy level. Community server implementations have been observed generating session IDs from sequential counters (session-1, session-2), short hex strings derived from Date.now(), or Node.js Math.random() — which uses a 64-bit Xorshift PRNG seeded at process start. An attacker who can observe any one session ID (e.g., via network tap on an internal network, log exposure, or timing the server restart) can predict the next session ID and inject tool results into an active session.
// VULNERABLE: sequential session ID generation
let sessionCounter = 0;
app.post('/mcp', (req, res) => {
if (req.body.method === 'initialize') {
const sessionId = `session-${++sessionCounter}`;
res.setHeader('Mcp-Session-Id', sessionId);
// Attacker can enumerate: session-1, session-2, session-3 ...
}
});
// VULNERABLE: Math.random() — predictable PRNG
function generateSessionId() {
return Math.random().toString(36).slice(2); // ~52 bits effective, seeded at start
}
// VULNERABLE: Date.now() — observable and low-entropy during burst
function generateSessionId() {
return Date.now().toString(16); // 11 hex chars — only millisecond precision
}
// SECURE: Node.js crypto.randomBytes — 128-bit cryptographic randomness
import { randomBytes } from 'node:crypto';
function generateSessionId() {
return randomBytes(16).toString('hex'); // 32 hex chars, 128 bits
}
// DETECTION (SkillAudit static analysis):
// 1. Scan for Mcp-Session-Id assignment patterns
// 2. Flag: Math.random(), Date.now(), sequential counters
// 3. Flag: short string literals as session ID seeds
// 4. Pass: crypto.randomBytes(≥16), crypto.randomUUID(), uuid v4
const sessionIdPatterns = [
/Math\.random\(\)/,
/Date\.now\(\)/,
/sessionCounter\+\+/,
/randomBytes\((\d+)\)/ // check arg >= 16
];
// Flag any session ID generator not using cryptographic source → SA-MCP-SHT-001
Session hijacking on a shared network is trivial with predictable IDs: On a corporate WiFi or any shared network segment, an attacker can send POST /mcp with Mcp-Session-Id: session-42 and observe whether the server returns a valid JSON-RPC response or a 404. Valid responses confirm the active session; the attacker can then inject tools/call results or call arbitrary tools using the victim’s authenticated session.
Mcp-Session-Id generated via Math.random(), Date.now(), or sequential counter: session token predictable; active sessions can be hijacked by brute-force enumeration. Detection: session ID generator not using crypto.randomBytes(≥16) or equivalent → CRITICAL.SA-MCP-SHT-002: CORS misconfiguration on POST /mcp endpoint
MCP servers running as local HTTP servers (localhost) or deployed as cloud services often set Access-Control-Allow-Origin: * on their /mcp endpoint for convenience. This is safe for unauthenticated endpoints. However, when the MCP server uses cookie-based authentication (common when deployed as a remote service fronted by a web application), combining Access-Control-Allow-Origin: * with Access-Control-Allow-Credentials: true violates the CORS spec but some server frameworks silently accept it, or authors set Access-Control-Allow-Origin: https://evil.com by reflecting the Origin header without validation. Any authenticated origin can then issue JSON-RPC tools/call requests using the victim’s session cookie.
// VULNERABLE: reflecting Origin header without allowlist
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', req.headers.origin || '*');
res.setHeader('Access-Control-Allow-Credentials', 'true');
// Any origin is now allowed with credentials → CORS bypass
next();
});
// VULNERABLE: wildcard + credentials (some frameworks silently combine these)
app.use(cors({
origin: '*',
credentials: true // spec-violating; some frameworks accept it
}));
// VULNERABLE: overly broad allowlist
const ALLOWED = ['https://app.example.com', 'https://staging.example.com'];
// Attacker registers 'staging.example.com.evil.net' — suffix match bypass
if (origin.endsWith('example.com')) // ← wrong substring check
// SECURE: strict origin allowlist with exact match
const ALLOWED_ORIGINS = new Set([
'https://app.example.com',
'https://staging.example.com'
]);
app.use((req, res, next) => {
const origin = req.headers.origin;
if (origin && ALLOWED_ORIGINS.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Vary', 'Origin');
}
next();
});
// DETECTION (SkillAudit):
// Fetch server OPTIONS /mcp with Origin: https://evil.example.com
// Check response headers:
// - ACAO: https://evil.example.com + ACAC: true → CRITICAL
// - ACAO: * + ACAC: true → CRITICAL (spec-violating)
// - ACAO missing entirely on /mcp → likely safe (browser blocks cross-origin POST)
const res = await fetch(mcpEndpoint, { method: 'OPTIONS' });
const acao = res.headers.get('access-control-allow-origin');
const acac = res.headers.get('access-control-allow-credentials');
if (acac === 'true' && (acao === '*' || acao === 'https://evil.example.com')) {
// → SA-MCP-SHT-002 CRITICAL
}
CORS bypass with credentials enables cross-site tool invocation: If CORS is misconfigured, any webpage the victim visits can fetch(mcpEndpoint, {credentials: 'include', method: 'POST', body: JSON.stringify({jsonrpc:'2.0',method:'tools/call',params:{name:'delete_file',arguments:{path:'/etc/passwd'}}})}) and the request will succeed with the victim’s session cookies. The attacker gets full tool execution in the victim’s MCP session from any webpage.
Access-Control-Allow-Origin reflects arbitrary Origin header or uses wildcard with Access-Control-Allow-Credentials: true: cross-site tool invocation possible using victim’s session. Detection: OPTIONS probe with attacker origin returns ACAO+ACAC → CRITICAL.SA-MCP-SHT-003: GET stream injection via missing session binding
The MCP spec allows clients to open a persistent GET SSE stream for server-initiated notifications. The spec requires the server to validate the Mcp-Session-Id header on GET requests before associating the stream with a session. Servers that accept GET /mcp without a valid session ID open an anonymous stream. If the server then uses the stream for any session-scoped notifications (tool progress, log messages, resource updates) and the routing logic uses the most-recently-opened stream rather than the session-bound stream, an attacker can pre-open a stream and intercept notifications intended for a legitimate session.
// VULNERABLE: GET stream not validated against session ID
app.get('/mcp', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
// No Mcp-Session-Id validation — anyone can open a stream
activeStreams.push(res); // stored globally, not per-session
req.on('close', () => {
activeStreams = activeStreams.filter(s => s !== res);
});
});
// Tool handler broadcasts to ALL streams (not session-bound)
async function sendProgress(sessionId, data) {
for (const stream of activeStreams) { // BUG: should be session-bound
stream.write(`data: ${JSON.stringify(data)}\n\n`);
}
}
// SECURE: validate session before accepting GET stream
app.get('/mcp', (req, res) => {
const sessionId = req.headers['mcp-session-id'];
if (!sessionId || !activeSessions.has(sessionId)) {
return res.status(401).json({ error: 'Invalid or missing Mcp-Session-Id' });
}
res.setHeader('Content-Type', 'text/event-stream');
// Bind stream to validated session only
activeSessions.get(sessionId).stream = res;
req.on('close', () => {
const session = activeSessions.get(sessionId);
if (session) session.stream = null;
});
});
// DETECTION (SkillAudit):
// Send GET /mcp without Mcp-Session-Id header
// If response is 200 + text/event-stream → stream opened without auth → HIGH
const res = await fetch(mcpEndpoint, {
method: 'GET',
headers: { 'Accept': 'text/event-stream' }
// Deliberately omitting Mcp-Session-Id
});
if (res.status === 200 && res.headers.get('content-type')?.includes('text/event-stream')) {
// → SA-MCP-SHT-003 HIGH
}
Anonymous streams enable notification interception without session hijacking: Even if the attacker cannot inject requests (session IDs are strong), an unauthenticated stream endpoint lets them receive notifications. Tool progress events and log messages frequently contain sensitive data about what tools were called, with what arguments, and what the results were — all without the attacker needing to know the session ID.
GET /mcp accepts stream without valid Mcp-Session-Id and routes notifications globally rather than per-session: attacker can intercept session notifications without knowing the session token. Detection: unauthenticated GET returns 200 SSE stream → HIGH.SA-MCP-SHT-004: DNS rebinding attack on localhost Streamable HTTP server
MCP servers using Streamable HTTP often bind to 127.0.0.1:PORT or localhost:PORT under the assumption that only local processes can reach them. DNS rebinding breaks this assumption: an attacker registers a subdomain (e.g., evil.attacker.com) that initially resolves to their own server, serves a malicious page, then changes the DNS record to resolve to 127.0.0.1. After the DNS TTL expires, the victim’s browser treats evil.attacker.com:PORT as same-origin with the local MCP server, allowing the malicious page to issue authenticated POST /mcp requests using the browser’s same-origin policy.
// VULNERABLE: MCP server that doesn't validate Host header
const express = require('express');
const app = express();
app.post('/mcp', express.json(), (req, res) => {
// No Host header validation — accepts requests for any Host value
handleJsonRpc(req.body, res);
});
app.listen(3000, '127.0.0.1'); // Local-only bind, but no Host check
// After DNS rebinding: browser sends:
// POST http://evil.attacker.com:3000/mcp
// Host: evil.attacker.com:3000
// (browser's same-origin policy allows because DNS now resolves to 127.0.0.1)
// Server sees the request and responds — attacker-controlled page receives results
// MITIGATION 1: Validate Host header — reject anything not localhost/127.0.0.1
app.use((req, res, next) => {
const host = req.headers.host?.split(':')[0];
if (host !== 'localhost' && host !== '127.0.0.1' && host !== '::1') {
return res.status(421).json({ error: 'Misdirected request: invalid Host' });
}
next();
});
// MITIGATION 2: Require Origin header and validate it for POST requests
app.use((req, res, next) => {
if (req.method === 'POST') {
const origin = req.headers.origin;
if (origin && !['http://localhost', 'http://127.0.0.1'].some(o => origin.startsWith(o))) {
return res.status(403).json({ error: 'Forbidden origin' });
}
}
next();
});
// MITIGATION 3: HTTPS with a localhost certificate (prevents rebinding — cert mismatch)
// Even better: bind to a non-default port AND require a pre-shared bearer token
// that the MCP client injects into Authorization header on each request.
// DETECTION (SkillAudit static analysis):
// Check: does server validate Host header on incoming requests?
// Check: does server bind to 0.0.0.0 (all interfaces) instead of 127.0.0.1?
// Flag: app.listen(PORT) without address → binds 0.0.0.0 → SA-MCP-SHT-004
// Flag: no Host header validation in middleware chain → SA-MCP-SHT-004
DNS rebinding bypasses network-level isolation entirely: The browser’s same-origin policy is based on (scheme, host, port) — not on the resolved IP address. Once the DNS TTL expires and the rebinding is complete, the attacker’s page has full same-origin access to the local MCP server with no indication to the user. Validation must happen at the HTTP layer (Host header, Origin header) not at the network layer.
Host header validation: DNS rebinding grants any webpage full same-origin access to the local MCP server. Detection: no Host/Origin validation in request middleware → CRITICAL.How SkillAudit detects SA-MCP-SHT patterns
| Pattern | Detection method | Severity |
|---|---|---|
| SA-MCP-SHT-001 | Static: session ID generator scanned for crypto.randomBytes vs Math.random/Date.now | CRITICAL |
| SA-MCP-SHT-002 | Dynamic: OPTIONS probe with attacker origin; ACAO+ACAC header analysis | CRITICAL |
| SA-MCP-SHT-003 | Dynamic: unauthenticated GET probe; checks for 200+SSE without session token | HIGH |
| SA-MCP-SHT-004 | Static: Host header middleware scan; listen() address argument; Origin validation | CRITICAL |
Run a SkillAudit scan before publishing your MCP server: Paste your GitHub URL at skillaudit.dev to get a graded security report covering all four SA-MCP-SHT patterns and 200+ additional MCP security checks.