Security Guide
MCP server CORS preflight security — OPTIONS preflight gate, wildcard with credentials, Origin reflection, simple request bypass, credentialed CORS
CORS is the most frequently misconfigured security mechanism in MCP server deployments — and the misconfigurations cluster around the same three patterns: reflecting the request Origin header verbatim, combining wildcard Access-Control-Allow-Origin with credentials, and failing to account for simple requests that bypass preflight entirely. Understanding the preflight mechanism is essential, but understanding its limits — particularly the simple-request exemption — is what separates a correctly secured MCP server from one that appears secure in testing and is exploitable in production.
What triggers a CORS preflight request
The browser's same-origin policy blocks cross-origin reads by default, but it does not prevent all cross-origin requests from being sent. The CORS specification introduces a tiered system: some requests are sent immediately and the response is blocked if CORS headers are absent or incorrect; other requests are preceded by a preflight — an OPTIONS request that asks the server whether the actual request should be allowed.
A preflight is triggered when ANY of the following conditions are true: the HTTP method is not one of GET, HEAD, or POST; the request includes headers other than Accept, Accept-Language, Content-Language, or Content-Type; or the Content-Type is anything other than application/x-www-form-urlencoded, multipart/form-data, or text/plain. In practice, almost every MCP API request triggers a preflight: PUT and DELETE are non-simple methods; Authorization and Content-Type: application/json are non-simple headers.
The OPTIONS preflight carries Access-Control-Request-Method and Access-Control-Request-Headers headers indicating what the browser intends to send. The server must respond with matching Access-Control-Allow-Methods and Access-Control-Allow-Headers values — and a correct Access-Control-Allow-Origin — before the browser sends the real request. If the preflight fails, the browser never sends the actual request. This is where developers get the mistaken impression that CORS is a request-level gate.
The preflight is not a security gate at the request level. A preflight failure prevents the browser from sending the actual request — for non-simple requests. But simple requests (GET, HEAD, POST with only simple headers and simple content types) are sent without a preflight. The server handles the request and CORS only controls whether the browser lets the calling script read the response.
Simple request bypass — the most commonly missed CORS fact
A "simple request" is a cross-origin request the browser sends without a preceding preflight. The browser dispatches the request, the server processes it completely, and only then does the browser inspect the CORS response headers. If Access-Control-Allow-Origin is absent or does not match the requesting origin, the browser blocks the calling script from reading the response — but the request already executed on the server.
This has a critical implication for MCP servers: CORS is not a protection against a request executing. It is only a protection against a cross-origin script reading the response. If a GET endpoint has side effects — a session initialization, a state mutation, a tool invocation, a log entry — those side effects occur before the browser enforces CORS. The attacker's JavaScript cannot read the server's response, but the operation has already happened.
This pattern is particularly common in MCP implementations that use GET requests for tool listing or session-establishment endpoints. A GET /mcp/session/start that creates an authenticated server-side session on access is vulnerable to cross-site execution even if the server returns an entirely correct CORS policy. An attacker's page can trigger session initialization without reading the response. CSRF protection — SameSite cookies, Origin-header validation, CSRF tokens — is the correct defense against this class of attack, not CORS.
// Simple request: the browser sends this WITHOUT a preflight first.
// The server receives and processes the request completely.
// CORS headers on the response control readability only — not executability.
// Attacker's page at https://evil.example:
fetch('https://mcp-api.example/session/init', {
method: 'POST',
// Content-Type: text/plain is a simple content type — no preflight triggered
headers: { 'Content-Type': 'text/plain' },
body: 'session-data',
credentials: 'include' // sends victim's session cookie
});
// The browser sends this request with the victim's cookies.
// The server initializes a session. The browser blocks the response from the script.
// CORS restriction is satisfied — the side effect (session created) already executed.
// GET with only simple headers — also a simple request:
fetch('https://mcp-api.example/tools/invoke?name=purge_cache', {
method: 'GET',
credentials: 'include'
});
// The tool executes on the server. Even with no CORS headers in the response,
// the browser just blocks the script from reading the result.
// The tool already ran.
CORS is NOT a substitute for CSRF protection. Any MCP endpoint that performs state-changing work on a GET or simple POST must be independently protected against cross-site request forgery. SameSite cookie attributes and CSRF tokens are the correct controls — CORS only governs cross-origin response readability.
Wildcard Access-Control-Allow-Origin with credentials — silently broken
The Fetch specification explicitly forbids combining Access-Control-Allow-Origin: * with Access-Control-Allow-Credentials: true. When both headers appear in a response to a credentialed request, the browser silently rejects the combination and refuses to share the response with the calling script. No JavaScript exception is thrown; the fetch simply fails as though CORS was absent entirely.
The prohibition exists by design: wildcard origins mean "any site can read this response," but credentials (cookies, client certificates, Authorization headers) are user-specific. Allowing any origin to read credentialed responses would allow any website to read authenticated data on behalf of the user — which is precisely the threat the same-origin policy exists to prevent. The spec authors made the combination a hard failure rather than a soft warning.
The danger arises when frameworks detect that credentials are present and automatically switch from returning * to reflecting the requesting Origin. This is an entirely different behavior: instead of the spec-prohibited wildcard-plus-credentials combination (which silently fails), the framework now grants credentialed CORS to every origin that asks. The developer never sees an error in testing because the reflected-origin response succeeds — but the allowlist is effectively empty.
// VULNERABLE: framework silently switches from wildcard to Origin reflection
// when credentials are detected in the request
// A naive custom CORS handler:
app.use((req, res, next) => {
const origin = req.headers['origin'];
const hasCredentials = req.headers['cookie'] || req.headers['authorization'];
if (hasCredentials && origin) {
// Developer's intent: "if they're logged in, allow their origin"
// Actual effect: reflect ANY origin when credentials are present
res.setHeader('Access-Control-Allow-Origin', origin); // full bypass
res.setHeader('Access-Control-Allow-Credentials', 'true');
} else {
// Unauthenticated: safe wildcard (no credentials, no sensitive data)
res.setHeader('Access-Control-Allow-Origin', '*');
}
if (req.method === 'OPTIONS') return res.status(204).end();
next();
});
// From https://attacker.example — credentialed request:
// Server sends: Access-Control-Allow-Origin: https://attacker.example
// Access-Control-Allow-Credentials: true
// Browser: origin matches, credentials allowed — response exposed to attacker's script
// SAFE: explicit Set-based allowlist with exact origin matching
const ALLOWED_ORIGINS = new Set([
'https://app.example',
'https://staging.example',
'https://console.example',
]);
function corsMiddleware(req, res, next) {
const requestOrigin = req.headers['origin'];
// Vary: Origin is required whenever the response differs based on Origin.
// Without it, a CDN or shared proxy may cache a response for one origin
// and serve it (with those CORS headers) to a different origin.
res.setHeader('Vary', 'Origin');
if (requestOrigin && ALLOWED_ORIGINS.has(requestOrigin)) {
// Only allowlisted origins receive CORS approval
res.setHeader('Access-Control-Allow-Origin', requestOrigin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.setHeader('Access-Control-Max-Age', '86400'); // cache preflight for 24 h
}
// Origins not in the allowlist receive no CORS headers — browser blocks response
if (req.method === 'OPTIONS') {
return res.status(204).end(); // terminate preflight here
}
next();
}
Origin reflection — granting CORS to any origin
Origin reflection is the single most dangerous CORS misconfiguration: the server reads the Origin request header and echoes it back as the Access-Control-Allow-Origin response header without checking it against any allowlist. Because the reflected value is a specific origin (not a wildcard), and because it always matches what the browser sent, every origin the browser presents becomes an approved origin.
An attacker's site at https://attacker.example sends a cross-origin request. The server reflects Access-Control-Allow-Origin: https://attacker.example. The browser checks the response: does the origin in the ACAO header match the request origin? Yes. Does the server permit credentials? Yes (if ACAC: true is also present). The browser allows the attacker's script to read the response. The same-origin policy has been completely bypassed.
Origin reflection appears in codebases for two consistent reasons: developers testing from localhost add reflection to make local development work and never remove it before production, and some CORS middleware libraries default to reflection when not given an explicit allowlist. The Vary: Origin header is a useful detection signal — if a response varies by Origin, the server is generating origin-specific CORS headers, which is either correct allowlist behavior or reflection.
// VULNERABLE: Origin reflected verbatim — complete CORS bypass
app.use((req, res, next) => {
const origin = req.headers['origin'];
if (origin) {
// Reflects whatever Origin the browser sent — grants CORS to any origin
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
}
if (req.method === 'OPTIONS') return res.status(204).end();
next();
});
// Exploit from https://attacker.example:
const profile = await fetch('https://mcp-api.example/user/profile', {
credentials: 'include'
}).then(r => r.json());
// profile now contains the victim's authenticated data.
// CORS intended to prevent exactly this — reflection defeated it entirely.
Credentialed CORS — both conditions required simultaneously
For a cross-origin fetch to include credentials — cookies, client certificates, or Authorization headers sent via credentials: 'include' — two independent conditions must both be satisfied in the response. First, the Access-Control-Allow-Origin header must name the specific requesting origin, not a wildcard. Second, the Access-Control-Allow-Credentials header must be present with the value true. The browser enforces both conditions independently. If either is absent, the browser blocks the response from the calling script — even though the request was sent and the server processed it.
The client side has a matching requirement: the fetch call must specify credentials: 'include' (or withCredentials: true for XMLHttpRequest). Without this client-side flag, the browser does not attach credentials and does not apply the credentialed CORS rules when reading the response. Both ends — client and server — must explicitly opt in to credentialed CORS for it to function. The most common debugging mistake is setting only one side and interpreting the resulting block as a server misconfiguration.
| Access-Control-Allow-Origin | Access-Control-Allow-Credentials | Client credentials: 'include' | Result |
|---|---|---|---|
* (wildcard) |
absent | No | Response readable — no credentials sent |
* (wildcard) |
true |
Yes | Silently blocked — spec-forbidden combination |
| Reflected origin | true |
Yes | Response readable — full CORS bypass |
| Allowlisted origin (matches) | true |
Yes | Response readable — correct credentialed CORS |
| Allowlisted origin (matches) | absent | Yes | Response readable — but credentials were not sent |
| Allowlisted origin (no match) | true |
Yes | Blocked — origin not in allowlist |
CORS does not protect navigation or form submissions
CORS applies to fetch() and XMLHttpRequest, and to CORS-mode resource loads such as <script crossorigin> and <link crossorigin>. It does NOT apply to HTML form submissions, which use browser navigation rather than the Fetch API. A cross-site <form method="POST" action="https://mcp-api.example/tool/invoke"> bypasses all CORS headers entirely: the browser navigates to the target URL using the form's data, the server receives and processes the POST, and CORS is never consulted.
Similarly, <img src="..."> and <iframe src="..."> loads use no-cors mode by default — they send requests without preflights and without requiring CORS response headers. An attacker can embed an MCP tool endpoint as an image source to trigger a GET request with the victim's credentials, regardless of the CORS configuration. The browser will not render the response as an image (wrong MIME type), but the request executes on the server.
CSRF protection is the correct defense for these scenarios: SameSite=Lax or SameSite=Strict cookies prevent cookies from being sent on cross-site navigation requests; CSRF tokens in request bodies prevent form submission attacks; and Origin-header validation provides a check layer for non-navigation cross-origin requests. CORS and CSRF address different threat vectors and are complementary controls — neither is a substitute for the other.
Preflight caching with Access-Control-Max-Age: setting Access-Control-Max-Age: 86400 on preflight responses tells the browser to reuse the result for 24 hours without re-sending the OPTIONS request. Setting it to 0 forces a preflight before every single cross-origin request, adding a full round-trip of latency. Zero or absent max-age also creates a timing oracle: an attacker can probe which origins are in the allowlist by timing whether preflight responses arrive faster (cache hit) or slower (new OPTIONS round-trip).
Wildcard subdomain allowlists and subdomain takeover
Some CORS implementations accept wildcard subdomain patterns — allowing any request from *.example.com — because it is more convenient than enumerating every subdomain explicitly. The CORS specification does not support wildcard subdomains natively, so servers implement this with a regex or string-prefix match. The security risk is subdomain takeover: if an attacker claims an unused subdomain of example.com (via an expired DNS CNAME pointing to an unclaimed cloud resource, GitHub Pages entry, or similar), they can make credentialed cross-origin requests to the MCP server from that subdomain and have CORS approve the response.
Subdomain takeovers are not rare edge cases. Common vulnerable patterns include Heroku apps with CNAMEs that were never cleaned up after app deletion, Azure Web Apps with dangling custom domains, and GitHub Pages deployments where the repository was deleted but the DNS entry was not removed. An MCP server with a *.example.com CORS allowlist is one unclaimed subdomain away from a full credentialed CORS bypass. The correct posture is an exact-match allowlist: enumerate app.example.com, staging.example.com, and console.example.com individually.
SkillAudit findings for CORS preflight misconfigurations
Origin header directly into the Access-Control-Allow-Origin response header without allowlist validation. Any origin — including https://attacker.example — receives CORS approval. Combined with Access-Control-Allow-Credentials: true, this is a full same-origin policy bypass granting any site read access to authenticated responses. Grade impact: −24.
Access-Control-Allow-Origin: * for unauthenticated requests, then detects credentials and switches to reflecting the request Origin — bypassing the spec-forbidden wildcard-plus-credentials prohibition through a separate misconfiguration. Credentialed CORS is now granted to every requesting origin. Grade impact: −22.
*.example.com) rather than enumerating exact origins. A subdomain takeover on any unclaimed subdomain grants the attacker's origin credentialed CORS access to the MCP server. Grade impact: −12.
Audit your MCP server for these issues
SkillAudit automatically detects CORS misconfigurations including Origin reflection, wildcard-plus-credentials, and simple-request side-effect exposure — paste a GitHub URL and get a graded report in 60 seconds.
Run a free audit →