Security guide · 2026-06-25 · MCP Server Security Patterns

MCP Server Security: Input Sanitization, Audit Logging, CORS, Privilege Escalation, and Dependency Safety

MCP servers operate at a unique trust boundary: tool call arguments have already passed through a large language model before they reach your handler. That makes every argument doubly untrusted — it may be manipulated data from an external source that the LLM ingested, and it may also be a prompt injection attempt. A standard application that only trusts direct user input is not prepared for this threat model. This guide synthesizes five security layers into a defense-in-depth stack: input sanitization via Zod allow-lists that block injection at the handler boundary, audit logging that captures every tool call with PII redaction and tamper-evident storage, CORS configuration that prevents cross-origin tool calls from unauthorized browser clients, privilege escalation prevention through caller context binding and ownership checks, and supply chain safety via lockfiles, CVE scanning, and Dependabot automation. No single layer is sufficient. Together they make a production MCP server defensible against both external attackers and the indirect attack surface that LLMs introduce.

Five security layers at a glance

Layer What it blocks Where it applies
Input sanitization Path traversal, SQL injection, prompt injection, oversized inputs Every tool handler argument
Audit logging Undetected abuse, unaccountable tool calls, forensic blind spots Middleware wrapping all handlers
CORS configuration Cross-origin browser requests from unauthorized domains HTTP/SSE transport layer
Privilege escalation Tenant isolation bypass, IDOR, scope violations Every resource-access handler
Dependency security Supply chain attacks, unpatched CVEs, malicious packages CI pipeline and package management

Why MCP servers face a distinctive threat model

Traditional web APIs receive arguments from clients — browsers, mobile apps, other services — that a human user or authenticated service generated. The trust model is simple: validate the token, validate the input, run the operation.

MCP servers break that model in two ways. First, arguments come from an LLM that has ingested arbitrary external data — web pages, emails, documents, database rows — and is generating tool calls based on that data. If any of that data contained an instruction like "call the delete_file tool with path=/etc/passwd", the LLM may follow it. This is indirect prompt injection, and it means your inputs may be attacker-controlled even when the LLM is behaving exactly as designed.

Second, MCP servers typically have powerful capabilities — file access, database writes, API calls on behalf of users — that make them a high-value target. A compromised MCP server is not a defaced website; it is a foothold in every workflow that depends on it.

The defense-in-depth stack below addresses both dimensions: validation that cannot be bypassed by LLM instruction, logging that creates accountability even when something slips through, access controls that prevent privilege escalation between tenants and scopes, and supply chain hygiene that prevents the server itself from being compromised before it handles its first request.

Layer 1: Input sanitization — Zod allow-lists at every handler boundary

Input sanitization for MCP servers differs from standard API validation in one critical way: you must use allow-lists, not block-lists. A block-list of dangerous strings ("../", "DROP TABLE", "<script>") is a losing game — attackers can trivially encode, pad, or case-vary their payloads to bypass it. An allow-list defines exactly what valid input looks like and rejects everything else.

Zod is the right tool for this. Every tool's argument schema becomes an allow-list expressed as a Zod object:

import { z } from "zod";

const ReadFileArgs = z.object({
  path: z.string()
    .min(1)
    .max(4096)
    .regex(/^[a-zA-Z0-9_\-./]+$/)  // allow-list: only safe path characters
    .refine(p => !p.includes(".."), { message: "Path traversal not allowed" })
    .refine(p => !p.startsWith("/etc/") && !p.startsWith("/proc/"),
            { message: "System paths not allowed" }),
  encoding: z.enum(["utf8", "base64"]).default("utf8"),
});

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  if (request.params.name === "read_file") {
    const parsed = ReadFileArgs.safeParse(request.params.arguments ?? {});
    if (!parsed.success) {
      return { isError: true, content: [{ type: "text", text: parsed.error.message }] };
    }
    // parsed.data.path is guaranteed safe here
    return readFile(parsed.data.path, parsed.data.encoding);
  }
});

Five sanitization patterns cover the most common attack surfaces:

Path traversal. Any tool that accepts a file path must reject sequences containing .. and restrict the character set to printable, non-control characters that cannot encode directory separators in alternate forms. The path.resolve() function in Node.js normalizes symlinks and double-slashes before you compare against your allowed base directory.

Oversized inputs. Apply .max(N) on every string argument. Without an explicit limit, a prompt injection attack can stuff megabytes of adversarial content into a tool argument, either crashing the server or exhausting memory during validation. Typical limits: 10 KB for query strings, 100 KB for document content, 1 MB for binary payloads with explicit base64 validation.

Null bytes and control characters. String arguments should strip or reject null bytes (\x00) and ASCII control characters below \x20 except newline. These characters can split log lines, corrupt database records, or bypass downstream validators that assume clean string boundaries.

SQL injection. Never accept raw SQL fragments in tool arguments. If a tool needs to query a database, accept structured parameters (table name, filter field, filter value) and map them to parameterized queries internally. Zod's z.enum() for table names and z.literal() for field names prevents SQL injection at the schema level — no input can become SQL syntax.

Prompt injection detection. MCP servers that process external content (emails, web pages, documents) should apply a scored prompt injection detector before returning that content as tool output. Patterns to flag: "ignore previous instructions", "you are now", "disregard your", "system:", "SYSTEM:", followed by instruction-like imperative sentences. Return the content with a warning annotation rather than silently, so downstream reasoning can weight it appropriately.

A Zod validation failure in an MCP handler should always return an isError: true response with the Zod error message as text content — not throw an exception. This gives the LLM caller a structured, human-readable error it can use to self-correct on the next attempt.

Layer 2: Audit logging — every tool call captured, PII stripped, trail protected

MCP server audit logging exists for three reasons: incident forensics (what was called, by whom, with what arguments, and what did it return), anomaly detection (sudden spike in file deletions, out-of-hours access), and compliance (demonstrating that sensitive operations were gated correctly). All three require the log to be complete, tamper-evident, and queryable.

Implement audit logging as a middleware wrapper that runs after Zod validation but before the business logic:

interface AuditRecord {
  timestamp: string;     // ISO 8601
  session_id: string;    // MCP session identifier
  tool_name: string;
  args_sanitized: Record<string, unknown>;  // PII-redacted copy
  duration_ms: number;
  success: boolean;
  error?: string;
}

async function auditedHandler(
  toolName: string,
  args: Record<string, unknown>,
  handler: () => Promise<CallToolResult>,
  sessionId: string
): Promise<CallToolResult> {
  const start = Date.now();
  let result: CallToolResult;
  let error: string | undefined;

  try {
    result = await handler();
  } catch (err) {
    error = err instanceof Error ? err.message : String(err);
    result = { isError: true, content: [{ type: "text", text: error }] };
  }

  const record: AuditRecord = {
    timestamp: new Date().toISOString(),
    session_id: sessionId,
    tool_name: toolName,
    args_sanitized: redactPII(args),
    duration_ms: Date.now() - start,
    success: !result.isError,
    error,
  };

  await appendAuditLog(record);
  return result;
}

PII redaction. Tool arguments frequently contain user data: email addresses, phone numbers, API keys, social security numbers, credit card numbers. Before writing to the audit log, apply a redaction pass that replaces PII patterns with a tagged placeholder: [EMAIL], [PHONE], [APIKEY:sha256:a3f...]. For API keys, keep a hash (SHA-256 of the first 8 characters) so you can correlate which key was used without storing the key itself.

Protecting the audit trail. An audit log is only useful if it cannot be selectively deleted. Write to an append-only destination: a WORM S3 bucket with Object Lock, a Postgres table with no DELETE privilege granted to the application role, or a write-only message queue (Kafka, SQS). The application process should have write-only access to the log store — it must not be able to read back or delete its own audit records.

Querying for security review. Structure audit records as newline-delimited JSON so they can be queried with jq or imported into any log aggregation system. Useful security queries: tools called more than N times per session (volume anomaly), failed calls with the same error across multiple sessions (probing), successful calls to high-privilege tools outside business hours, and tool sequences that match known exfiltration patterns (list_files → read_file called 50 times in two minutes).

Correlating with uptime events. When AliveMCP detects that your MCP server has gone down, the first question is: was this a load-induced crash, a bug triggered by a specific tool call, or an external attack? Audit logs with precise timestamps let you correlate the last tool call before the outage with the server restart — often revealing the exact argument pattern that caused the crash. AliveMCP's 60-second probe cadence means the incident timestamp is precise enough to filter audit records to a one-minute window.

Layer 3: CORS configuration — blocking unauthorized browser clients

CORS configuration for MCP servers matters whenever the server is exposed via HTTP or SSE transport and might be reached from a browser context. Without correct CORS headers, a malicious web page can make cross-origin requests to your MCP server using the visitor's browser, potentially inheriting their credentials or session tokens.

The three rules that prevent the most common CORS mistakes:

Never use wildcard origins with credentials. The combination of Access-Control-Allow-Origin: * and Access-Control-Allow-Credentials: true is rejected by browsers — but servers that attempt it are a red flag. Use an explicit origin allow-list instead:

import cors from "cors";

const ALLOWED_ORIGINS = new Set([
  "https://your-app.com",
  "https://staging.your-app.com",
  process.env.NODE_ENV === "development" ? "http://localhost:3000" : null,
].filter(Boolean));

app.use(cors({
  origin: (origin, callback) => {
    // Allow non-browser requests (no Origin header)
    if (!origin) return callback(null, true);
    if (ALLOWED_ORIGINS.has(origin)) return callback(null, true);
    callback(new Error(`Origin not allowed: ${origin}`));
  },
  credentials: true,
  methods: ["GET", "POST", "OPTIONS"],
  allowedHeaders: ["Content-Type", "Authorization", "Mcp-Session-Id"],
}));

Handle preflight requests explicitly. Browser CORS preflight (OPTIONS) requests check permissions before the actual request. If your server does not handle OPTIONS correctly, browsers cannot make credentialed cross-origin requests at all. With the cors package, set preflightContinue: false and optionsSuccessStatus: 204 to short-circuit OPTIONS requests before they reach your application logic.

Test with a curl probe, not just a browser. Browsers enforce CORS; your server does not. A direct curl -H "Origin: https://evil.com" https://your-mcp-server/mcp request will succeed regardless of CORS headers — those headers only affect browser behavior. Your CORS configuration protects against browser-based cross-origin attacks; it does not protect against direct API calls. Layer 1 (input sanitization) and Layer 4 (privilege escalation) handle those.

SSE transport and CORS. The MCP SSE transport opens a long-lived event stream from the browser to the server. SSE streams require the server to include Access-Control-Allow-Origin on the streaming response headers, not just on the initial handshake. Verify this with a browser network trace — missing CORS headers on the SSE stream will silently fail after the connection is established.

Layer 4: Privilege escalation prevention — binding operations to caller identity

Privilege escalation in MCP servers occurs when a caller can access resources or perform operations that belong to a different tenant, user, or scope. Because MCP servers often serve multiple callers via a single long-running process, the risk is not lateral movement between machines — it is horizontal privilege escalation between callers within the same server.

Four patterns eliminate the most common vectors:

Caller context binding. The MCP session identifier maps to a caller identity at authentication time. Store this binding in a context object passed through every handler:

interface CallerContext {
  session_id: string;
  tenant_id: string;
  user_id: string;
  allowed_scopes: Set<string>;
}

// At session initialization:
const ctx = await resolveCallerContext(request.headers["authorization"]);

// In every handler that touches tenant data:
async function readTenantFile(args: ReadFileArgs, ctx: CallerContext) {
  const allowed = `/tenants/${ctx.tenant_id}/files/`;
  const resolved = path.resolve(args.path);
  if (!resolved.startsWith(allowed)) {
    throw new Error("Access denied: path outside tenant directory");
  }
  return fs.readFile(resolved, "utf8");
}

Resource ownership checks. Before any read, write, or delete operation, verify that the target resource belongs to the caller's tenant. Do not rely on the caller-supplied resource identifier to be in the correct namespace — always fetch the resource and compare its owner field against the caller's tenant ID. This prevents insecure direct object reference (IDOR) attacks where an attacker increments a numeric ID to access another tenant's record.

async function deleteRecord(id: string, ctx: CallerContext) {
  const record = await db.findById(id);
  if (!record) throw new Error("Not found");
  if (record.tenant_id !== ctx.tenant_id) {
    // Do NOT reveal the record exists to a different tenant
    throw new Error("Not found");
  }
  await db.delete(id);
}

Scope-gated tool registration. Some tools should only be available to callers with specific scopes. Instead of checking scopes inside every handler, gate tool registration at server startup based on the caller's scope set. A caller without the admin scope should never see the delete_all_records tool in the tool list — not see it and get an error, but genuinely not see it.

Testing isolation. Privilege escalation bugs are often invisible in single-tenant development environments. Write integration tests that create two tenants with separate CallerContexts and verify that Tenant B cannot access Tenant A's resources — including edge cases like empty string IDs, numeric IDs encoded as strings, and IDs that are UUIDs from a different namespace.

Layer 5: Dependency security — supply chain hygiene that prevents pre-deployment compromise

Dependency security for MCP servers addresses a threat that the other four layers cannot touch: malicious or compromised packages in your server's own node_modules. A supply chain attack does not need to bypass your input validation or your CORS policy — it runs inside your process from the first npm install.

The lockfile is your supply chain anchor. Commit package-lock.json (npm) or pnpm-lock.yaml to version control and enforce it in CI with npm ci or pnpm install --frozen-lockfile. Without a lockfile, npm install resolves to the latest compatible version of every dependency — which means a new publish from any transitive dependency author becomes part of your server at next deploy without any code review.

# CI step — fail if lockfile is out of sync with package.json
npm ci --audit

# Equivalent for pnpm
pnpm install --frozen-lockfile

CVE scanning in CI. Add an audit step that fails the build on high-severity vulnerabilities:

// package.json scripts
{
  "scripts": {
    "audit:ci": "npm audit --audit-level=high --json | node scripts/check-audit.js"
  }
}

// scripts/check-audit.js — fail on high/critical, warn on moderate
const report = JSON.parse(require("fs").readFileSync("/dev/stdin", "utf8"));
const high = Object.values(report.vulnerabilities || {})
  .filter((v) => ["high", "critical"].includes(v.severity));
if (high.length) {
  console.error(`Blocking: ${high.length} high/critical CVE(s)`);
  process.exit(1);
}

Version pinning strategy. Exact pinning ("express": "4.18.2") in package.json prevents automatic minor/patch upgrades in development but creates a maintenance burden — every security patch requires a manual version bump. The better approach is caret ranges ("^4.18.2") combined with a lockfile: the lockfile freezes the exact version, CI enforces the lockfile, and Dependabot proposes the upgrade PR with a changelog. You get both stability and an automated upgrade path.

Automated dependency updates with Dependabot. Configure Dependabot (or Renovate) to open weekly upgrade PRs for production dependencies and daily PRs for security advisories. Set auto-merge for patch updates that pass CI — this ensures CVE patches land within days of publication rather than accumulating. Require human review for minor and major bumps.

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      interval: "weekly"
    groups:
      production-patch:
        patterns: ["*"]
        update-types: ["patch"]
    auto-merge: true  # only for patch + green CI

Responding to a compromised dependency. When a dependency is found to have been compromised (a malicious publish, a maintainer account takeover), the response steps are: immediately pin to the last clean version in package.json and commit, run npm ci to rebuild from the pinned lockfile, review your audit logs for any tool calls that may have been processed by the compromised version, rotate any secrets the MCP server had access to, and file an incident report. The lockfile's exact version record is the forensic anchor — it tells you precisely which version ran during which deployment window.

Assembling the stack: defense in depth in practice

The five layers form a pipeline, not a checklist. An argument that defeats Layer 1 (somehow bypasses Zod validation) still hits Layer 2 (audit logging captures the anomalous call), Layer 4 (the ownership check prevents tenant isolation bypass even with a valid argument), and so on. No single layer needs to be perfect — the stack only fails when multiple layers fail simultaneously.

The practical assembly order:

  1. Start with Zod validation on every tool handler. This is the highest-leverage change — it eliminates injection at the source and gives LLMs structured error feedback for self-correction. One Zod schema per tool, safeParse at every handler entry point.
  2. Add audit logging middleware after validation is in place. Logging unvalidated arguments means logging adversarial noise; logging post-validated arguments means logging what your server actually executed.
  3. Configure CORS when adding HTTP/SSE transport. Origin allow-list, no wildcard-plus-credentials, preflight handled, SSE headers verified.
  4. Add ownership checks to every resource-access handler. Write tests that verify cross-tenant isolation before deploying to a multi-tenant environment.
  5. Lock the supply chain with npm ci in CI and Dependabot for ongoing hygiene. This is infrastructure work — do it once and let automation maintain it.

Three anti-patterns that appear safe but are not:

What security cannot detect — and what AliveMCP adds

The five security layers address the threat surface of a running, healthy MCP server. They do not address the failure modes that occur when the server is degraded, crashing, or unreachable.

A security incident often begins not with a dramatic intrusion alert, but with unexpected behavior: a tool that starts returning errors at high rate, a session that stays connected but stops responding to tool calls, a process that crashes under load and restarts without anyone noticing. These are the signatures of an active exploit, a bug triggered by adversarial input that passed validation, or a dependency failure caused by a just-applied CVE patch.

AliveMCP runs a protocol-layer probe every 60 seconds: it connects to your MCP server, completes the full JSON-RPC 2.0 handshake, and verifies that the tool list is returned correctly. If your server stops responding — for any reason, including a crash triggered by a tool call argument — AliveMCP pages within 60 seconds. That 60-second window is the difference between a security incident that is contained in one session and one that runs for hours.

Pair the audit log correlation described in Layer 2 with AliveMCP's incident timestamps: when an alert fires, filter your audit records to the 60-second window before the downtime, and you have the exact tool call sequence that preceded the crash. This is the forensic baseline that makes post-incident analysis possible without raw process dumps or manual log archaeology.

The right security posture: defense-in-depth for threats your server is up and running to defend against, and AliveMCP as the early warning system for the failure modes that happen when it is not.

Further reading