Topic: MCP server insecure defaults security
MCP server insecure defaults — scaffolding configuration risks
MCP server starter templates and scaffolding tools ship with development-friendly defaults: CORS open to all origins, no authentication required, debug logging enabled, verbose error responses with stack traces. These defaults make the first run frictionless. They become security vulnerabilities the moment the server is deployed beyond a developer's local machine. SkillAudit regularly finds MCP servers in production that were scaffolded six months ago and never hardened — every insecure default still active.
Default 1: Wildcard CORS (Access-Control-Allow-Origin: *)
Nearly every MCP server HTTP template ships with cors({ origin: '*' }). This is appropriate for local development — any browser-based client can reach the server. In production behind any form of cookie-based authentication, it is a CSRF enabler and an information disclosure risk:
// What scaffolding generates (development default):
import cors from 'cors';
app.use(cors()); // Equivalent to Access-Control-Allow-Origin: *
// The problem: wildcard CORS + cookie auth = CSRF
// fetch('https://your-mcp-server.com/tools/call', {
// credentials: 'include', // cross-origin page can read the response
// ...
// })
// With wildcard CORS, the cross-origin response body is readable.
// Combined with cookies: full CSRF with response reading.
// Production hardening:
const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS?.split(',') ?? [];
app.use(cors({
origin: (origin, callback) => {
if (!origin) return callback(null, false); // block non-browser requests via CORS
if (ALLOWED_ORIGINS.includes(origin)) return callback(null, true);
callback(new Error('Not allowed by CORS'));
},
credentials: true, // required for cookie auth
methods: ['GET', 'POST'], // only what the MCP protocol uses
}));
Default 2: No authentication required
Development MCP servers almost never require authentication — the developer runs the server locally and connects their Claude client directly. Templates rarely include even placeholder authentication. When these servers are deployed to a shared VPS or internal network address, they are unauthenticated by default:
// Typical scaffolded tool endpoint — no auth middleware
app.post('/tools/call', async (req, res) => {
const result = await callTool(req.body.params.name, req.body.params.arguments);
res.json(result);
});
// Any request from any origin with network access can call any tool.
// If the tool has file access, this is unauthenticated file read.
// If the tool has shell exec, this is unauthenticated RCE.
// Minimum viable authentication for a shared deployment
import { createHmac, timingSafeEqual } from 'crypto';
function authenticate(req, res, next) {
// Option 1: bearer token (simple, suitable for single-user CLI integration)
const auth = req.headers.authorization ?? '';
const [scheme, token] = auth.split(' ');
if (scheme !== 'Bearer' || !token) {
return res.status(401).json({ error: 'Authentication required' });
}
const expectedToken = Buffer.from(process.env.MCP_AUTH_TOKEN ?? '', 'utf8');
const receivedToken = Buffer.from(token, 'utf8');
if (expectedToken.length !== receivedToken.length ||
!timingSafeEqual(expectedToken, receivedToken)) {
return res.status(401).json({ error: 'Invalid token' });
}
next();
}
app.post('/tools/call', authenticate, toolCallHandler);
Default 3: Debug mode and verbose logging
Templates typically set NODE_ENV=development or equivalent, enabling verbose logging that includes full request bodies, stack traces, and sometimes credential values. The debug flag is often a simple boolean in config:
// Common in templates:
const DEBUG = process.env.DEBUG ?? true; // defaults to true
if (DEBUG) {
console.log('Tool call:', JSON.stringify({ name, arguments: args }));
// arguments may contain passwords, API keys, personal data
}
// Error handler with stack traces:
app.use((err, req, res, next) => {
console.error(err.stack); // stack trace to stdout = may appear in log aggregators
res.status(500).json({
error: err.message,
stack: DEBUG ? err.stack : undefined, // stack exposed when DEBUG=true
});
});
// Production hardening:
const isDev = process.env.NODE_ENV === 'development';
// Structured logging with credential redaction
function logToolCall(name: string, args: Record) {
const REDACT_KEYS = ['password', 'secret', 'token', 'key', 'api_key', 'credential'];
const safeArgs = Object.fromEntries(
Object.entries(args).map(([k, v]) =>
REDACT_KEYS.some(r => k.toLowerCase().includes(r)) ? [k, '[REDACTED]'] : [k, v]
)
);
logger.info({ tool: name, args: safeArgs });
}
// Error handler without stack exposure in production
app.use((err: Error, req, res, next) => {
logger.error({ msg: err.message, stack: isDev ? err.stack : undefined });
res.status(500).json({
error: isDev ? err.message : 'Internal server error',
// No stack in production response
});
});
Default 4: Listening on 0.0.0.0 instead of 127.0.0.1
Template servers often start on 0.0.0.0 (all interfaces) for convenience — this makes the server reachable from other devices on the same network, not just localhost. An MCP server intended for local use but listening on all interfaces is reachable by anyone on the same Wi-Fi or LAN:
// Scaffolding default:
app.listen(3000); // Node.js default: binds 0.0.0.0:3000
// Explicit 0.0.0.0 (common in Docker templates):
app.listen(3000, '0.0.0.0');
// Safe local-only binding:
app.listen(3000, '127.0.0.1');
For containerized deployments where the server must be reachable from outside the container, bind to 0.0.0.0 but add authentication — and ensure the container is not exposed to the public internet without a firewall or VPN.
Default 5: No rate limiting
Scaffolded servers have no rate limiting. An LLM-driven client can make thousands of tool calls per minute — intentionally (as an amplification attack exploiting the LLM's tool-calling loop) or accidentally (a prompt injection that triggers a loop). Without rate limiting, the server will exhaust downstream API quotas, consume disk space, or incur unexpected API costs:
// Add rate limiting as early middleware
import rateLimit from 'express-rate-limit';
const toolCallLimiter = rateLimit({
windowMs: 60 * 1000, // 1-minute window
max: 100, // 100 tool calls per minute per IP
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many tool calls — rate limit exceeded' },
});
app.use('/tools/call', toolCallLimiter);
Default 6: No Content-Security-Policy or security headers
MCP servers that serve any HTML (a status page, an OAuth consent page, a configuration UI) often inherit the framework's default of sending no security headers. Without CSP and other security headers, XSS in server-rendered content has no mitigation:
// Apply security headers to all responses
import helmet from 'helmet';
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"], // no inline scripts
objectSrc: ["'none'"],
frameAncestors: ["'none'"],
},
},
hsts: { maxAge: 31536000, includeSubDomains: true },
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
}));
The SkillAudit insecure-defaults check
SkillAudit's static analysis looks for the configuration patterns above across the server's source files and configuration:
cors()orcors({ origin: '*' })without credential: true handling — Medium/High depending on auth model- Endpoints without authentication middleware on a server that declares HTTP transport — High
console.logof request arguments at INFO level — Medium (potential credential leakage)app.listen(port)without explicit127.0.0.1binding — Low (context-dependent)- No rate limiting middleware on tool call endpoints — Medium
- Error handler that includes
err.stackin the response body — Medium
The insecure defaults check is part of every SkillAudit report — it catches the class of vulnerabilities that developers introduce by accepting scaffold-generated configurations without reviewing them for production readiness.
Check whether your MCP server is running with insecure scaffolding defaults in production.
Run a free audit →