Security reference · CORS · Cross-Origin
MCP server CORS preflight security
CORS (Cross-Origin Resource Sharing) is the browser mechanism that governs which origins can read responses from your MCP server's HTTP API. Misconfigured CORS is one of the most prevalent web security vulnerabilities — and MCP servers are commonly affected because developers enable permissive CORS to simplify local development and forget to tighten it before production. This reference covers the simple vs. preflight request distinction, the wildcard origin trap, origin reflection attacks, missing Vary headers, and the correct CORS policy for MCP API endpoints.
Simple vs. preflight requests: the dangerous distinction
A common misconception: "CORS protects against cross-origin requests." CORS does not prevent requests — it controls whether JavaScript in the browser can read the response. More importantly, certain "simple" requests bypass the OPTIONS preflight entirely and are sent directly to your server, regardless of your CORS policy.
| Request type | Preflight required? | CORS prevents sending? |
|---|---|---|
| GET with simple headers | No | No — request is sent; CORS only controls whether JS can read the response |
POST with Content-Type: application/x-www-form-urlencoded | No | No — the request is sent; server receives it |
POST with Content-Type: application/json | Yes | Yes — preflight OPTIONS must succeed before the request is sent |
| PUT, PATCH, DELETE | Yes | Yes — preflighted |
Any request with custom headers (e.g., Authorization) | Yes | Yes — preflighted |
The implication for MCP servers: if your server has any GET endpoint that triggers a state change (which it shouldn't — that's a REST design error — but some do), or if you have a legacy form-POST endpoint, those can be triggered cross-origin without a preflight. CORS alone cannot protect against CSRF on simple requests. Use SameSite=Strict cookies or CSRF tokens for state-changing endpoints.
The wildcard origin trap
The most common CORS misconfiguration: using Access-Control-Allow-Origin: * on API endpoints that also require credentials.
// WRONG — browsers reject this combination
response.headers.set('Access-Control-Allow-Origin', '*');
response.headers.set('Access-Control-Allow-Credentials', 'true');
// Browsers treat this as invalid and block the request
// ALSO WRONG — wildcards on cookie-authenticated APIs // Any malicious website can make authenticated requests if cookies are included // by the browser (via SameSite=None; Secure cookies or CORS credentials mode)
If your MCP server uses session cookies for its web dashboard or OAuth flow, and you've set Access-Control-Allow-Origin: *, you have a CSRF vector even on non-simple requests — a malicious site can exfiltrate your session response. The browser blocks reading the response body with * + credentials, but the request still lands on your server.
Origin reflection: the permissive CORS mistake
To avoid the * + credentials incompatibility while still allowing multiple origins, some MCP servers reflect the incoming Origin header directly back:
// VULNERABLE — origin reflection without validation
app.use((req, res, next) => {
const origin = req.headers.origin;
if (origin) {
// Reflects ANY origin — equivalent to * but passes the credentials check
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Vary', 'Origin');
}
next();
});
This is functionally equivalent to Access-Control-Allow-Origin: * with credentials, which browsers block at the HTTP level — but the origin reflection trick bypasses that block by sending back a specific origin rather than the wildcard. Now any website can make credentialed cross-origin requests to your MCP server and read the responses.
// SAFE — allowlist validation before reflecting
const ALLOWED_ORIGINS = new Set([
'https://skillaudit.dev',
'https://app.skillaudit.dev',
'http://localhost:3000', // development only
]);
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');
}
// If origin is not in allowlist: no CORS headers — browser blocks cross-origin read
next();
});
The missing Vary: Origin header
When your server returns different Access-Control-Allow-Origin values for different requesting origins (because you're doing allowlist validation), you must include Vary: Origin in the response. Without it, a CDN or intermediate cache may cache the response with one origin's CORS headers and serve it to a different origin, either incorrectly blocking or incorrectly allowing access.
// Always include Vary: Origin when the CORS header value depends on the request
res.setHeader('Vary', 'Origin');
// If you're using Caddy with a CORS plugin or manual header config:
# Caddy
header {
Access-Control-Allow-Origin "{http.request.header.Origin}"
Vary "Origin"
}
Preflight caching: Preflight responses can also be cached by the browser. The Access-Control-Max-Age header controls how long (in seconds) the preflight result is cached. Default is browser-dependent (0 to 86400s). Set to 86400 (1 day) to avoid repeated preflight overhead in MCP clients that make frequent API calls from browser contexts.
Correct CORS policy for MCP API endpoints
Most MCP clients are not browsers — they're Node.js agent harnesses that don't enforce CORS at all. CORS is only relevant when your MCP server is accessed directly from browser JavaScript (e.g., a browser-based agent UI or a web app that calls your MCP API directly).
// Complete CORS middleware for a public MCP API
// (no credentials — MCP auth is via Bearer token in Authorization header,
// which triggers preflight, so wildcard is safe here)
app.use('/mcp', (req, res, next) => {
if (req.method === 'OPTIONS') {
// Preflight response
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type');
res.setHeader('Access-Control-Max-Age', '86400');
return res.status(204).end();
}
// Actual request — allow any origin for public API with Bearer auth
// (Bearer auth in Authorization header means cookies are not the auth mechanism,
// so wildcard origin is acceptable)
res.setHeader('Access-Control-Allow-Origin', '*');
next();
});
// For admin UI routes that use cookies — strict allowlist
app.use('/admin', (req, res, next) => {
const origin = req.headers.origin;
const ADMIN_ORIGINS = new Set(['https://admin.skillaudit.dev']);
if (origin && ADMIN_ORIGINS.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Vary', 'Origin');
}
next();
});
Bearer token vs. cookie auth and CORS: If your MCP API uses Bearer tokens in the Authorization header (the MCP spec recommends this), wildcard CORS is safe for the API endpoint — because the Authorization header triggers preflight, and the browser can't send cookies cross-origin to a wildcard-CORS endpoint. Only endpoints that use cookie authentication need the strict allowlist pattern.
Subdomain wildcard risk
Some CORS implementations allow any subdomain via a regex or endsWith check:
// VULNERABLE — endsWith subdomain check
const origin = req.headers.origin;
if (origin && origin.endsWith('.skillaudit.dev')) {
// An attacker can register evil-skillaudit.dev and bypass this check
// because "evil-skillaudit.dev".endsWith(".skillaudit.dev") is FALSE
// BUT: "attacker.skillaudit.dev" COULD be registered as a subdomain takeover target
res.setHeader('Access-Control-Allow-Origin', origin);
}
// SAFE — full domain validation
const TRUSTED_SUFFIX = '.skillaudit.dev';
if (origin && (origin === 'https://skillaudit.dev' ||
(origin.endsWith(TRUSTED_SUFFIX) && !origin.includes('..') &&
/^https:\/\/[a-z0-9-]+\.skillaudit\.dev$/.test(origin)))) {
res.setHeader('Access-Control-Allow-Origin', origin);
}
Subdomain wildcard CORS is dangerous if any subdomain has been abandoned (subdomain takeover). An attacker who registers or takes over dev.skillaudit.dev gets the same CORS permissions as app.skillaudit.dev. Use an explicit allowlist of subdomains rather than a suffix pattern.
SkillAudit findings for CORS misconfigurations
* + credentials browser restriction via reflected origin.
Access-Control-Allow-Origin: * on cookie-authenticated endpoints — request body and side effects are accessible to any origin; response is blocked but server state changes execute.
endsWith or regex suffix) without subdomain inventory check — abandoned subdomain takeover grants full CORS access to attackers.
Vary: Origin header when CORS response varies by origin — CDN or proxy cache may serve incorrect CORS headers to different origins, causing silent data exposure.
SameSite=Strict cookie on state-changing endpoints — CORS preflight only protects complex requests; simple POST requests bypass preflight and can change state cross-origin.
Run a full CORS and cross-origin security audit on your MCP server at SkillAudit. The audit checks origin validation, Vary headers, preflight correctness, and CSRF exposure alongside the full security report.
Related references: CSRF and state parameter security · OIDC security · HSTS for MCP servers