Topic: mcp server cold start security

MCP server cold start security — initialization vulnerabilities and race conditions

Most MCP server security analysis focuses on steady-state operation: what happens when a tool is called with valid or malicious input after the server is fully running. But a distinct class of vulnerability exists in the cold start window — the period between when the server process starts and when all initialization is complete. During this window, the auth middleware may not be wired up, credentials may be partially loaded, config may be in an intermediate state, and a race between initialization and the first incoming tool call can let an unauthenticated request through.

Cold start vulnerability 1: race between init and first request

Node.js MCP servers often start accepting connections as soon as the transport binds, while async initialization continues in the background. If the initialization includes loading auth middleware, a race window exists where the server accepts tool calls before the auth check is wired up.

// VULNERABLE: auth loaded asynchronously after server starts accepting
const server = new Server({ name: 'my-server', version: '1.0.0' });

// This handler is registered and callable BEFORE loadAuth() completes
server.setRequestHandler(CallToolRequestSchema, async (req) => {
  // auth may not be initialized yet — no check guards this
  return handlers[req.params.name](req.params.arguments);
});

// Auth loads in the background — race window exists between transport.start() and this
loadAuth().then(auth => {
  globalAuth = auth;
});

// Server starts accepting connections here — BEFORE auth is ready
const transport = new StdioServerTransport();
await server.connect(transport);

// ============================================================

// SAFE: synchronous readiness gate — no requests accepted until ready
let ready = false;
let globalAuth = null;

server.setRequestHandler(CallToolRequestSchema, async (req) => {
  if (!ready) {
    return {
      content: [{ type: 'text', text: 'Server is initializing — please retry' }],
      isError: true,
    };
  }
  // auth is guaranteed initialized here
  const authResult = globalAuth.verify(req);
  if (!authResult.ok) {
    return { content: [{ type: 'text', text: 'Unauthorized' }], isError: true };
  }
  return handlers[req.params.name](req.params.arguments);
});

// Load all dependencies BEFORE connecting the transport
globalAuth = await loadAuth();
ready = true;

// Only now accept connections
const transport = new StdioServerTransport();
await server.connect(transport);

Cold start vulnerability 2: fail-open initialization error handling

Some servers catch initialization errors and continue running in a degraded mode — they log the error but don't abort. If the initialization error was a failed credential load, the server now runs without credentials, which often means all auth checks pass by default (since there's nothing to check against).

// VULNERABLE: catch-and-continue on credential load failure
let creds;
try {
  creds = await loadCredentials();
} catch (err) {
  console.error('Failed to load credentials:', err.message);
  // creds remains undefined — auth checks that test `if (!creds)` now skip
}

function checkAuth(token) {
  if (!creds) return true; // BUG: fail-open — no credentials means all requests pass
  return creds.validate(token);
}

// SAFE: fail-closed — abort the process if credentials can't be loaded
let creds;
try {
  creds = await loadCredentials();
  if (!creds || !creds.isValid()) {
    throw new Error('Credential validation failed after load');
  }
} catch (err) {
  process.stderr.write(JSON.stringify({
    event: 'INIT_FATAL',
    reason: 'credential_load_failed',
    message: err.message,
    ts: new Date().toISOString(),
  }) + '\n');
  process.exit(1); // Hard exit — do not continue with partial initialization
}

Cold start vulnerability 3: async config race window

Config that determines which paths, hostnames, or operations are allowed is often loaded asynchronously and cached. If a request arrives during the window between startup and config load completion, the server may operate with empty allowlists — which often means no operations are blocked at all.

// VULNERABLE: allowlist loaded async, requests accepted before load completes
let allowedPaths = []; // empty = allow everything in a "if not in blocklist" pattern

setTimeout(async () => {
  allowedPaths = await fetchAllowedPaths(); // loaded after startup
}, 0);

function isPathAllowed(p) {
  // BUG: if called before setTimeout fires, allowedPaths is empty
  // If the logic is "block paths NOT in the allowlist", this allows everything
  return allowedPaths.length === 0 || allowedPaths.includes(p);
}

// SAFE: load config synchronously before accepting requests, fail closed if missing
async function loadConfig() {
  const paths = await fetchAllowedPaths();
  if (!paths || paths.length === 0) {
    throw new Error('Allowlist is empty — refusing to start with unrestricted access');
  }
  return { allowedPaths: paths };
}

// Called before server.connect() — no race window
const config = await loadConfig();

function isPathAllowed(p) {
  return config.allowedPaths.includes(p);
}

Cold start vulnerability 4: partial secrets manager load

Servers that load credentials from a secrets manager (AWS Secrets Manager, HashiCorp Vault, etc.) may successfully connect to the manager but only retrieve some of the required secrets — perhaps because of a permission issue or network timeout on one call. If the server treats partial success as full success, it runs with some secrets missing, which may mean certain auth paths are skipped or tokens validated against a null value.

// SAFE: validate all required secrets are present before proceeding
const REQUIRED_SECRETS = ['DB_PASSWORD', 'API_SIGNING_KEY', 'WEBHOOK_SECRET'];

async function loadAllSecrets(secretsManager) {
  const results = await Promise.allSettled(
    REQUIRED_SECRETS.map(name => secretsManager.getSecret(name))
  );

  const secrets = {};
  const failures = [];

  for (let i = 0; i < results.length; i++) {
    const name = REQUIRED_SECRETS[i];
    const result = results[i];
    if (result.status === 'fulfilled' && result.value) {
      secrets[name] = result.value;
    } else {
      failures.push({ name, reason: result.reason?.message ?? 'empty value' });
    }
  }

  if (failures.length > 0) {
    // Log names only — never log secret values, even in errors
    throw new Error(`Failed to load required secrets: ${failures.map(f => f.name).join(', ')}`);
  }

  return secrets;
}

SkillAudit detection

SkillAudit's Security axis checks for cold-start vulnerabilities via static analysis of the server initialization flow: whether server.connect() is called before or after async initialization completes, whether initialization error handlers call process.exit() or continue, and whether allowlist/credential variables are checked for emptiness before use in security decisions. The Maintenance axis also flags servers where the initialization flow has high cyclomatic complexity — a signal that the startup sequence may have untested branches.

Run a SkillAudit scan to see whether your server's initialization path has cold-start vulnerability patterns before shipping it to a registry.


Related: Fail-secure patterns · Race condition security · Reject vs error