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:

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.

ConfigurationSafe?Effect
origin: '*', no credentialsSafe for public APIsAny origin can call; no cookies/auth forwarded
origin: '*' + credentials: trueBlocked by browserBrowser refuses to send; spec violation
Reflect any Origin + credentials: trueDangerousAny website can make authenticated requests as the user
Explicit allowlist + credentials: trueSafeOnly 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