Guide · MCP Security
MCP server CORS configuration
Cross-Origin Resource Sharing (CORS) controls which web origins are permitted to call your MCP server's HTTP endpoints from a browser. For HTTP-transport MCP servers, CORS misconfiguration is one of the most common security vulnerabilities: a wildcard origin with credentials enabled lets any website make authenticated requests on behalf of a logged-in user. A strict allowlist — matching production, staging, and localhost explicitly — prevents this while keeping your developer experience intact.
TL;DR
Use the cors package with an explicit origin allowlist (never origin: '*' when credentials are involved). Set credentials: true only if you actually use cookies or HTTP auth. Cache preflight responses with maxAge: 600 (10 minutes). Expose only the custom headers your clients need. Validate the Origin header against your allowlist rather than echoing it back blindly — the latter creates an open proxy for cross-site requests.
Why CORS matters for MCP servers
MCP servers with HTTP transport (Streamable HTTP or SSE) expose HTTP endpoints that browser-based clients — web UIs, browser extensions, and embedded agent interfaces — call directly. Without CORS configuration, browsers block these cross-origin requests entirely. With CORS misconfiguration, you open your API to cross-site request forgery (CSRF) and cross-origin data theft.
The three scenarios that require hardened CORS:
- Web UI calling your MCP server — your React / Vue frontend at
app.example.comcalls the MCP server atapi.example.com - Browser extension — a user-installed extension calls your public MCP endpoint from a browser tab on any origin
- Multi-tenant platform — your MCP server serves multiple customers each from their own subdomain; each subdomain must be individually allowed
CLI and server-side clients (Node, Python, curl) are not constrained by CORS — only browsers enforce it. If your MCP server is called exclusively by non-browser clients, you still need CORS configuration but the risk surface is smaller.
Basic CORS setup with the cors package
import express from 'express';
import cors from 'cors';
const app = express();
const ALLOWED_ORIGINS = (process.env.CORS_ALLOWED_ORIGINS ?? '')
.split(',')
.map(o => o.trim())
.filter(Boolean);
// For local development, always allow localhost variants
if (process.env.NODE_ENV !== 'production') {
ALLOWED_ORIGINS.push(
'http://localhost:3000',
'http://localhost:5173',
'http://127.0.0.1:3000',
);
}
app.use(cors({
origin: (requestOrigin, callback) => {
// Allow requests with no origin (curl, server-side clients, healthchecks)
if (!requestOrigin) return callback(null, true);
if (ALLOWED_ORIGINS.includes(requestOrigin)) {
callback(null, requestOrigin); // Echo the matched origin, not '*'
} else {
callback(new Error(`CORS: origin ${requestOrigin} not allowed`));
}
},
credentials: true, // Required if you use cookies or HTTP auth
maxAge: 600, // Cache preflight for 10 minutes (600 seconds)
allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
exposedHeaders: ['X-Request-ID', 'Retry-After'],
methods: ['GET', 'POST', 'OPTIONS'],
}));
Set CORS_ALLOWED_ORIGINS in your environment to a comma-separated list of permitted origins: https://app.example.com,https://staging.example.com. This approach works with any hosting configuration and does not require code changes when adding a new allowed origin.
The wildcard + credentials mistake
The most dangerous CORS misconfiguration is pairing origin: '*' with credentials: true. Browsers actively block this combination (per the CORS spec), but some implementations work around it by reflecting the request's Origin header back verbatim with a separate credentials: true — which achieves the same dangerous result.
| Configuration | Safe? | Effect |
|---|---|---|
origin: '*', no credentials | Safe for public APIs | Any origin can call; no cookies/auth forwarded |
origin: '*' + credentials: true | Blocked by browser | Browser refuses to send; spec violation |
Reflect any Origin + credentials: true | Dangerous | Any website can make authenticated requests as the user |
Explicit allowlist + credentials: true | Safe | Only approved origins can make authenticated requests |
The "reflect any Origin" pattern appears when developers write res.setHeader('Access-Control-Allow-Origin', req.headers.origin) without a check. This is functionally equivalent to origin: '*' for credential-bearing requests and is exploitable by any website the user visits.
Preflight request handling
Browsers send an HTTP OPTIONS preflight before cross-origin requests that use non-simple methods (POST, PUT, DELETE) or non-simple headers (Authorization, Content-Type: application/json). Your server must respond correctly to preflights or the actual request is blocked.
// The cors() middleware handles OPTIONS automatically — but ensure your router
// doesn't intercept OPTIONS before cors() can respond.
// Incorrect: OPTIONS hits auth middleware first, returns 401 before cors() runs
app.use(authMiddleware); // ← blocks preflight
app.use(cors(corsOptions));
// Correct: cors() before auth so preflights get through
app.use(cors(corsOptions));
app.use(authMiddleware);
// Explicitly handle OPTIONS at the router level if needed
app.options('*', cors(corsOptions)); // Pre-flight for all routes
The maxAge: 600 setting tells the browser to cache the preflight result for 10 minutes, reducing the number of extra round-trips. Chrome caps maxAge at 7200 seconds; Firefox at 86400. For development, set it low (60) to pick up CORS changes immediately.
Dynamic origin validation patterns
For multi-tenant SaaS where each customer gets a subdomain (customer1.app.example.com), maintain the allowlist as a set of allowed patterns:
const ALLOWED_ORIGIN_PATTERNS = [
/^https:\/\/[\w-]+\.app\.example\.com$/, // All customer subdomains
/^https:\/\/app\.example\.com$/, // Primary app
/^https:\/\/staging\.example\.com$/, // Staging
];
function isAllowedOrigin(origin: string): boolean {
return ALLOWED_ORIGIN_PATTERNS.some(re => re.test(origin));
}
app.use(cors({
origin: (requestOrigin, callback) => {
if (!requestOrigin) return callback(null, true);
if (isAllowedOrigin(requestOrigin)) {
callback(null, requestOrigin);
} else {
callback(new Error('CORS: origin not in allowlist'));
}
},
credentials: true,
maxAge: 600,
}));
Be careful with regex patterns — an overly broad pattern like /example\.com/ would match evil-example.com. Always anchor patterns to the full origin string using ^ and $ and require https:// for production origins.
Exposing custom response headers
By default, browsers only expose a few simple response headers to JavaScript (Content-Type, Cache-Control, etc.). If your MCP server returns custom headers that your client needs to read — like X-Request-ID for tracing or Retry-After for rate limiting — you must explicitly expose them:
app.use(cors({
// ...other options...
exposedHeaders: [
'X-Request-ID', // Correlation ID for distributed tracing
'Retry-After', // Rate-limit backoff instruction
'X-RateLimit-Remaining',
'X-RateLimit-Reset',
],
}));
Headers not in exposedHeaders are filtered by the browser before JavaScript can read them — response.headers.get('X-Request-ID') returns null even if the server sent the header.
Testing your CORS configuration
Use curl to simulate a preflight and check the response headers:
# Simulate a preflight from https://app.example.com
curl -s -X OPTIONS https://api.yourserver.com/mcp \
-H 'Origin: https://app.example.com' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: Content-Type, Authorization' \
-D -
# Expected response headers:
# Access-Control-Allow-Origin: https://app.example.com
# Access-Control-Allow-Credentials: true
# Access-Control-Allow-Methods: GET,POST,OPTIONS
# Access-Control-Allow-Headers: Content-Type, Authorization, X-Request-ID
# Access-Control-Max-Age: 600
# Test that a disallowed origin is rejected
curl -s -X OPTIONS https://api.yourserver.com/mcp \
-H 'Origin: https://evil.example.com' \
-H 'Access-Control-Request-Method: POST' \
-D -
# Expected: No Access-Control-Allow-Origin header in response
In a browser, open DevTools → Network, make a fetch to your MCP endpoint from a test page on a different origin, and verify the response headers match your allowlist configuration.
CORS and MCP server uptime
CORS misconfiguration causes browser-side errors that look like network failures — the browser blocks the request before it completes, so your server-side logs may show nothing. If AliveMCP shows your server as healthy but users report browser clients failing to connect, CORS is a likely suspect. AliveMCP's probes use server-side HTTP (no CORS) — they verify transport health but not browser accessibility. Test the browser path separately with curl or a DevTools network trace.
Further reading
- MCP server authentication — JWT, API keys, and session verification
- MCP server security monitoring — threat detection and alerting
- MCP server audit logging — capture and query tool call records
- MCP server rate limiting — throttle abuse at the edge
- MCP server HTTP transport — Streamable HTTP and SSE setup
- MCP server middleware — Express middleware patterns for MCP
- MCP server input validation — Zod schemas and boundary checks
- AliveMCP — uptime monitoring for HTTP-deployed MCP servers