MCP server security misconfiguration: default credentials, verbose errors, and missing security headers
Security misconfiguration (OWASP A05:2021) is consistently the most common finding in SkillAudit scans. Unlike complex vulnerabilities that require architectural changes, misconfigurations are preventable by startup validation, safe error handling, and a one-time hardening pass. The attack surface is wide: default OAuth secrets, stack traces returned as tool errors, missing HTTP security headers, verbose startup logging, and admin tools with no authentication.
Why misconfiguration is so common in MCP servers
MCP server development follows a fast path: clone a template, set a placeholder secret (changeme, your-secret-here), run locally with NODE_ENV=development, ship to production without changing the configuration. Placeholder values that were intentional in development become production secrets. Verbose error handling that was useful for debugging returns stack traces to every caller. Security headers that were omitted because they aren't needed for localhost:3000 never get added before the server goes live.
These are not hard problems. They are oversight problems. A startup configuration validator and a one-time hardening review eliminates most of them.
Pattern 1: startup configuration validation with Zod
Validate every required configuration value at server startup, before any tool is registered or any connection is accepted. Use Zod to define the schema with explicit constraints that reject placeholder values:
import { z } from 'zod';
const ConfigSchema = z.object({
// Must be present and not a placeholder
OAUTH_CLIENT_SECRET: z.string()
.min(32, 'Client secret too short (minimum 32 characters)')
.refine(s => !['changeme', 'your-secret-here', 'placeholder', 'secret', 'test'].includes(s.toLowerCase()),
'OAUTH_CLIENT_SECRET appears to be a placeholder value'),
JWT_SECRET: z.string()
.min(32, 'JWT secret too short (minimum 32 characters)')
.refine(s => s !== 'your-jwt-secret', 'JWT_SECRET is a placeholder'),
API_KEY: z.string()
.regex(/^[a-zA-Z0-9_-]{20,}$/, 'API_KEY does not match expected format'),
// URL format validation
DATABASE_URL: z.string().url().startsWith('postgresql://'),
// Non-production values blocked in production
NODE_ENV: z.enum(['development', 'test', 'staging', 'production']),
ALLOW_UNAUTHENTICATED: z.enum(['false']).optional()
.refine(v => process.env.NODE_ENV !== 'production' || v !== 'true',
'ALLOW_UNAUTHENTICATED must be false in production'),
});
// Runs at startup — throws if config is invalid
const config = ConfigSchema.parse(process.env);
If the configuration is invalid, the server refuses to start. A placeholder secret in a production deploy causes an immediate crash with a clear error message, not a silent deployment that accepts connections with the placeholder credential.
Pattern 2: safe error messages in tool responses
Raw JavaScript Error objects propagated to tool callers expose internal paths, stack frames, database connection strings, and sometimes credentials. Define a ToolError abstraction that sanitizes error messages before they reach the caller:
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
class ToolError extends Error {
readonly code: ErrorCode;
readonly safeMessage: string;
constructor(code: ErrorCode, safeMessage: string, cause?: unknown) {
super(safeMessage);
this.code = code;
this.safeMessage = safeMessage;
// Log the cause internally but never include it in the response
if (cause) {
logger.error('tool_error_internal', {
message: safeMessage,
cause: cause instanceof Error ? cause.message : String(cause),
stack: cause instanceof Error ? cause.stack : undefined,
});
}
}
}
// In tool handlers — wrap all errors
try {
const result = await db.query('...');
return formatResult(result);
} catch (e) {
if (e instanceof ToolError) throw e;
// Generic catch — never expose e.message to the caller
throw new ToolError(
ErrorCode.InternalError,
'An internal error occurred processing your request',
e
);
}
The safeMessage is what the caller receives. The raw error (which may contain a database connection string in its message) is logged internally. The caller gets a sanitized error code and a message that describes what failed without revealing how.
Pattern 3: security header middleware
If your MCP server serves any HTTP responses — including its own health check, tool list endpoint, or web transport — add security headers. These protect against clickjacking, MIME sniffing, and cross-origin data leakage:
import express from 'express';
const securityHeaders = (req: express.Request, res: express.Response, next: express.NextFunction) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('X-XSS-Protection', '0'); // Let CSP handle XSS, not this legacy header
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
res.setHeader('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'");
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
res.removeHeader('X-Powered-By'); // Don't leak server technology
next();
};
app.use(securityHeaders);
For MCP servers that only use stdio transport (no HTTP), these headers are not applicable. But any server with an HTTP transport, health endpoint, or admin interface needs them. Missing X-Content-Type-Options is a MEDIUM finding in SkillAudit; missing Content-Security-Policy on an admin interface is HIGH.
Pattern 4: startup configuration audit log
Log what was loaded at startup — but scrub sensitive values. An audit-safe startup log confirms the configuration is as expected without exposing credentials to the log stream:
function logStartupConfig(config: AppConfig) {
const SENSITIVE_KEYS = new Set([
'OAUTH_CLIENT_SECRET', 'JWT_SECRET', 'API_KEY', 'DATABASE_URL',
'REDIS_URL', 'ENCRYPTION_KEY', 'WEBHOOK_SECRET'
]);
const safeConfig: Record<string, string> = {};
for (const [key, value] of Object.entries(config)) {
if (SENSITIVE_KEYS.has(key)) {
// Log that the value is present and its length/prefix, not the value itself
safeConfig[key] = `[present, length=${String(value).length}]`;
} else {
safeConfig[key] = String(value);
}
}
logger.info('startup_config', safeConfig);
}
Logging [present, length=64] for JWT_SECRET tells you the secret was loaded without exposing it to anyone who reads the startup log. The length check also confirms that a placeholder (typically 10–15 characters) was not used — a 64-character secret is clearly not changeme.
Pattern 5: security.txt and responsible disclosure
Publish a /.well-known/security.txt file on any HTTP interface your MCP server exposes. This signals to security researchers where to report vulnerabilities and demonstrates that you have a disclosure process:
# security.txt
Contact: security@yourorg.com
Expires: 2027-01-01T00:00:00.000Z
Preferred-Languages: en
Policy: https://yourorg.com/security-policy
Acknowledgments: https://yourorg.com/security-hall-of-fame
A security.txt file is not a vulnerability fix — but SkillAudit includes it as a MEDIUM finding under Maintenance because it demonstrates security program maturity. The presence or absence of a disclosure channel is a signal that security is or isn't taken seriously. See the security policy template for a full responsible disclosure policy to pair with your security.txt.
What SkillAudit checks for security misconfiguration
Security misconfiguration findings in SkillAudit scans span three sub-scores:
- Credentials (HIGH): placeholder values in configuration (
changeme,your-secret-here,placeholder), short secrets (under 16 characters), or default credentials in source files - Security (MEDIUM): missing security header middleware on HTTP transport, missing
X-Content-Type-Options, verbose error messages that include stack traces or internal paths - Maintenance (MEDIUM): no startup configuration validation, no
security.txt, missingSECURITY.mdfile in repository
Misconfiguration findings are among the fastest to fix — most can be resolved in under an hour by adding a startup validator, wrapping error handling, and adding a header middleware. The C-to-A remediation guide treats misconfiguration as the first priority pass because of its high fix-to-impact ratio. For the full checklist of pre-deployment security checks, see the 15-point security audit checklist.
Find security misconfigurations in your MCP server
SkillAudit scans for placeholder credentials, verbose error messages, missing security headers, and no startup validation. Most misconfigurations take under an hour to fix once identified.
Run free scan →