Topic: mcp server cors security

MCP server CORS security — configuring cross-origin policies for HTTP-transport servers

CORS is a browser-enforced policy, which means stdio-transport MCP servers that communicate via JSON-RPC over stdin/stdout have no CORS surface at all — the browser is not involved. But HTTP-transport MCP servers listen on TCP ports, and any web page the user visits can make cross-origin requests to http://localhost:PORT. An HTTP-transport MCP server with Access-Control-Allow-Origin: * is a cross-origin request forgery target from any malicious web page the user visits. CORS configuration is not optional for HTTP-transport MCP servers.

The wildcard trap

The most common CORS misconfiguration in HTTP-transport MCP servers is a wildcard origin policy added during development to stop browser CORS errors:

// Dangerous — allows any web page to call this MCP server
app.use(cors({ origin: '*' }));

// Also dangerous — equivalent to wildcard for cross-origin reads
app.use((req, res, next) => {
  res.setHeader('Access-Control-Allow-Origin', req.headers.origin || '*');
  next();
});

Both patterns reflect the actual origin or allow all origins without validation. The first is easy to spot; the second is subtler — it echoes the request's Origin header back as the allowed origin, which has the same effect as wildcard for every origin that sends a request. SkillAudit's security axis checks for both patterns in the repo tree, including in middleware files, server.ts, index.ts, and any file that imports cors from cors or sets Access-Control-Allow-Origin manually.

Pattern 1 — deny by default (stdio-primary servers)

If your MCP server's primary transport is stdio and you only added HTTP as a secondary debugging interface or development convenience, the right CORS policy is to reject all cross-origin requests:

import express from 'express';
const app = express();

// Explicitly deny all cross-origin requests — no CORS headers set
app.use((req, res, next) => {
  const origin = req.headers.origin;
  if (origin) {
    // Cross-origin request detected — reject it
    res.status(403).json({ error: 'Cross-origin requests not allowed.' });
    return;
  }
  next();
});

// Or use the cors package with an empty origin list (denies all cross-origin):
app.use(cors({ origin: false }));

The tradeoff: this breaks MCP clients that use the HTTP transport via browser-based agents (e.g., Claude.ai web interface calling a locally-running HTTP-transport server). For local-only development servers that are always accessed via the Claude Code desktop client (not a browser), deny-by-default is the right choice.

Pattern 2 — explicit allowlist for known agent origins

For HTTP-transport servers that need to accept requests from browser-based MCP clients, use a static allowlist of the specific origins that should be permitted:

import cors from 'cors';

// Static allowlist — only these origins can make cross-origin requests
const ALLOWED_ORIGINS = new Set([
  'https://claude.ai',
  'https://cursor.sh',
  'http://localhost:3000',   // local development agent UI only
]);

app.use(cors({
  origin: (origin, callback) => {
    // Allow requests with no origin (same-origin, curl, desktop clients)
    if (!origin) return callback(null, true);
    if (ALLOWED_ORIGINS.has(origin)) return callback(null, true);
    callback(new Error(`Origin ${origin} not allowed by CORS policy.`));
  },
  methods: ['POST', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: false   // Set to true only if you need cookies — avoid if possible
}));

The allowlist is a constant in the source — do not make it configurable via an env var (CORS_ALLOWED_ORIGINS=* in a .env file defeats the purpose if an operator sets wildcard). For production deployments, remove http://localhost:3000 from the list.

Pattern 3 — localhost port binding restriction

For HTTP-transport servers that should only be accessible from the local machine (not from external networks or browser requests), bind to the loopback interface and reject requests that don't come from localhost — even if the CORS policy has already been applied:

// 1. Bind only to loopback — not accessible from external network
server.listen(PORT, '127.0.0.1', () => {
  console.error(`MCP server listening on 127.0.0.1:${PORT}`);
});

// 2. Verify the connecting IP at the request level (defense in depth)
app.use((req, res, next) => {
  const ip = req.socket.remoteAddress;
  if (ip !== '127.0.0.1' && ip !== '::1' && ip !== '::ffff:127.0.0.1') {
    res.status(403).json({ error: 'Connections only accepted from localhost.' });
    return;
  }
  next();
});

// Note: binding to 127.0.0.1 does not prevent browser-based cross-origin attacks —
// a malicious web page can still reach localhost:PORT via fetch().
// The loopback binding prevents external network access; CORS policy handles browser attacks.

The comment in the snippet above is important: loopback binding does not prevent cross-origin attacks from the browser. A web page running on the user's machine (or in the user's browser) can still reach http://localhost:PORT. The two defenses are orthogonal: loopback binding prevents external network access; CORS policy prevents browser-initiated cross-origin requests. Both are needed for a secure HTTP-transport server.

The CSRF companion threat

CORS prevents cross-origin reads (the browser blocks the attacker's page from reading the response). But CORS does not prevent cross-origin writes on its own — a form submission or a navigation to the endpoint can still trigger a tool call without the browser's CORS check applying. Add CSRF token validation for any tool call that causes side effects:

// Simple CSRF token check for HTTP-transport MCP servers
const CSRF_TOKEN = process.env.MCP_CSRF_TOKEN ?? randomBytes(32).toString('hex');

app.use('/mcp', (req, res, next) => {
  // Allow only POST (the MCP protocol uses POST for all tool calls)
  if (req.method !== 'POST' && req.method !== 'OPTIONS') {
    return res.status(405).json({ error: 'Method not allowed.' });
  }
  // Verify CSRF token in header — set by the MCP client, not by any cross-origin form
  const provided = req.headers['x-mcp-csrf-token'];
  if (provided !== CSRF_TOKEN) {
    return res.status(403).json({ error: 'CSRF token mismatch.' });
  }
  next();
});

The MCP client (Claude Code, Cursor, Windsurf) sets the X-Mcp-Csrf-Token header on every request. A cross-origin form submission cannot set custom headers, so it cannot pass the CSRF check. This is defense-in-depth alongside the CORS allowlist — both checks together cover the cross-origin attack surface.

SkillAudit coverage

The security axis of the SkillAudit engine checks for wildcard Access-Control-Allow-Origin headers in HTTP-transport server code, reflected-origin patterns, and missing loopback binding when the server accepts external connections. Check your MCP server at skillaudit.dev — paste the GitHub URL for a 60-second audit.