Skip to content
← Blog

MCP security in production

What the MCP specification already mandates, what the 2026 coding-agent CVEs actually broke, and the controls that hold: audience validation, tool pinning, sandboxing, egress policy and audit.

·16 min read
  • MCP
  • Security
  • IAM
  • AI Agents
  • OAuth

An MCP server is not just another API. It is an instruction channel that a model reads and acts on. Every tool name, every parameter description and every string a tool returns lands in the same context window as the user's request, and the model has no reliable way to tell data from orders. That single property is what makes Model Context Protocol security a different discipline from API security — and it is why the failures of 2026 have been so consistent.

Diagram of an MCP trust boundary: an agent host and client on one side, three MCP servers exposing tools on the other, with an authenticated call crossing the boundary
The boundary that matters is not the network edge. It is the line between the model's context and anything that can act.

This is an operational guide, not a threat-model essay. It covers what the specification already mandates, what the 2026 incidents actually broke, and the controls that hold up in production — with the commands to verify each one. If you have been treating MCP as “just an integration format”, this is the part that bites.

What MCP actually changes in your threat model

In a conventional integration, a developer decides which call is made, with which arguments, at which point in the code. With MCP the client hands every connected server's tool definitions to the model, and the model chooses what to invoke and with what parameters. OWASP puts the consequence plainly: MCP combines prompt injection, supply-chain risk and the confused deputy problem in one place.[owaspcs]

Three properties follow, and each one breaks an assumption that normal application security relies on.

  • Tool metadata is executable-ish. Descriptions and JSON Schemas are read by the model as guidance. A poisoned description field is closer to injected code than to documentation.
  • Tool output is untrusted input. A web page, an issue title or a database row returned by a tool re-enters the context with the same standing as the system prompt.
  • The model sees every server at once. A malicious server's tool description can change how the agent uses a different, trusted server. That is tool shadowing, and per-server review does not catch it.[owaspcs]

The specification is explicit that it does not solve this for you. Authorization is OPTIONAL for MCP implementations; when HTTP transport is used it SHOULD follow the spec, and stdio implementations are told to take credentials from the environment instead.[spec-auth] Everything else — sandboxing, tool integrity, egress control — is left to whoever ships the host, client or server.

The 2026 incidents that set the bar

Two of the clearest data points this year did not come from exotic research setups. They came from vendors' own default configurations.

At Black Hat USA on 5 August 2026, Novee Security ran its tests against Anthropic's, Google's and OpenAI's coding agents in the configurations those vendors ship by default. Two CVEs came out of the work and both are patched. What the two actually allowed is very different, and that difference is the useful part.[ghsa-google][ghsa-anthropic]

IssueWhat actually failedFixed in
CVE-2026-12537 — Gemini CLI, CVSS v4 10.0 as scored by the CNA[cve12537]OS command injection in the container launcher, reached through a crafted .gemini/.env file. Code ran on the host of a headless CI platform before the sandbox started.Gemini CLI 0.39.1 · run-gemini-cli 0.1.22[ghsa-google]
CVE-2026-54316 — Claude Code, CVSS v4 6.0 (NVD v3.1: 9.1)[cve54316]huggingface.co was pre-approved as a bare hostname for WebFetch, so any path on it — including attacker-controlled model repos — was auto-approved. HuggingFace counts those fetches server-side, so the download counters became a covert out-of-band channel for exfiltrating data the agent could reach — files, environment variables, command output. Confidentiality only: no code execution. Affected ≥ 0.2.54 and < 2.1.163.Claude Code 2.1.163[ghsa-anthropic]
OpenAI Codex — no CVE, no version bumpTwo Codex passes shared one checkout, so the first pass could write AGENTS.md, which the second pass loaded as its own instructions. Fixed at the workflow level, not the product level.Workflow separation; guidance now treats repository instruction files as untrusted input[codex]

The pattern is worth stating precisely, because it generalises well beyond these three products: the failure sat in the harness, not the model. One component marked a value safe and a later component acted on it with more authority. As of reporting on 7 August 2026 neither CVE appeared in CISA's Known Exploited Vulnerabilities catalogue, and nothing in the public record shows either chain used against a real target — the point is the class of defect, not a live campaign.

Read that alongside the operating model in DevOps vs MLOps vs LLMOps: if you cannot name the exact bundle that produced an action — model, prompt, tool schema, policy — you cannot explain an incident, and you cannot trust a rollback.

The baseline the specification already mandates

A surprising share of “MCP security” work is just doing OAuth properly. The 2025-06-18 revision reclassified the MCP server as a pure OAuth 2.1 resource server, and the 2025-11-25 revision refined discovery and consent further.[spec-log] If you run a remote MCP server, the following are not recommendations.[oauth21][rfc9700][nsa]

Discovery: RFC 9728, not a README

MCP servers MUST implement OAuth 2.0 Protected Resource Metadata and advertise at least one entry in authorization_servers; clients MUST use it for discovery. On an unauthenticated request the server returns 401 with a WWW-Authenticate header pointing at the metadata document.[spec-auth][rfc9728]

# 1. Provoke the challenge and read the pointer
curl -sSI https://mcp.example.com/mcp | grep -i '^www-authenticate'
# www-authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"

# 2. Protected Resource Metadata must name the authorization server
curl -sS https://mcp.example.com/.well-known/oauth-protected-resource | jq '{resource, authorization_servers, scopes_supported}'

# 3. Authorization Server Metadata must be reachable and PKCE-capable
curl -sS https://auth.example.com/.well-known/oauth-authorization-server \
  | jq '{issuer, token_endpoint, code_challenge_methods_supported}'

Audience binding: the control that stops token reuse

Clients MUST send the RFC 8707 resource parameter in both the authorization and the token request, using the canonical URI of the target server — and MUST send it whether or not the authorization server supports it.[rfc8707] Servers MUST validate that a presented token was issued for them, and reject it otherwise.[spec-auth]

Token passthrough — accepting a token that was not issued to you, then forwarding it downstream — is explicitly forbidden. The spec's reasoning is operational rather than theoretical: it destroys the audit trail, bypasses rate limits and request validation that depend on audience, and turns your server into a proxy for anyone holding a stolen token.[spec-sec] If your MCP server calls an upstream API, it acts as a client to that API and needs its own token.

# Resource-server side. Reject anything not minted for this exact resource.
CANONICAL = "https://mcp.example.com/mcp"   # RFC 8707 resource identifier

def authorize(token: dict) -> None:
    aud = token.get("aud")
    aud = aud if isinstance(aud, list) else [aud]
    if CANONICAL not in aud:
        raise Unauthorized("token audience does not include this server")   # 401
    if not required_scopes(token).issuperset(scopes_for_current_tool()):
        raise Forbidden("insufficient scope")                               # 403
    # Never forward `token` upstream. Mint a separate one for the upstream API.

Client registration: CIMD is now the preferred default

The 2025-11-25 revision added OAuth Client ID Metadata Documents as a recommended client-registration mechanism (SEP-991), alongside OpenID Connect Discovery support and incremental scope consent via WWW-Authenticate.[spec-log][cimd] With CIMD the client_id is an HTTPS URL that resolves to a small JSON document describing the client; the authorization server fetches it, validates the registered redirect URIs and applies its own policy — no pre-coordination, and no pile of throwaway registrations from Dynamic Client Registration.

That matters because DCR is one of the ingredients in the confused-deputy attack. When a proxying MCP server uses a static client ID with a third-party authorization server and lets clients register dynamically and the third party sets a consent cookie, an attacker can register a client with their own redirect_uri, reuse the victim's consent cookie to skip the consent screen, and collect the authorization code. The mitigation is per-client consent stored server-side and checked before the third-party flow, plus exact-match redirect URI validation and single-use state.[spec-sec]

None of this is new identity engineering — it is the same discipline as designing IAM that scales and the same trade-offs that show up when choosing between Keycloak and Entra ID. Reuse the authorization server you already operate and audit; do not let each MCP server grow its own.

Ten failure modes, and the control that actually closes each

The OWASP MCP Top 10 is a beta (v0.1) living document with a next release planned for October 2026, so treat it as a checklist rather than a certification target.[owasp10] Mapped to controls, it looks like this.

OWASP MCP riskControl that closes it
MCP01 Token mismanagement & secret exposureShort-lived, per-server, per-user tokens in OS credential storage — never in config files. Refresh-token rotation for public clients.[spec-auth]
MCP02 Privilege escalation via scope creepStart at a minimal scope set; elevate incrementally through WWW-Authenticate scope="…" challenges. No *, no omnibus scopes, no publishing the whole catalogue in scopes_supported.[spec-sec]
MCP03 Tool poisoning (rug pull, schema poisoning, shadowing)Pin a SHA-256 over the canonical JSON of name + description + input schema at approval time; re-hash before every execution and fail closed on drift.[owaspcs]
MCP04 Supply chain & dependency tamperingPin versions and digests, verify checksums or signatures, scan dependencies, check for typosquats before install.[owaspcs]
MCP05 Command injection & executionNo shell interpolation of model-supplied strings; argument arrays only, strict JSON Schema with additionalProperties: false and pattern constraints.
MCP06 Intent-flow subversion / contextual prompt injectionTreat every tool return as data. Strip instruction-like markup, prefer structured extraction over raw HTML, alert on imperative patterns in tool output.[owaspcs]
MCP07 Insufficient authentication & authorizationOAuth 2.1 with PKCE, audience validation, per-request authorization. Sessions MUST NOT be used for authentication.[spec-sec]
MCP08 Lack of audit and telemetryLog every invocation with full parameters, caller identity, tool hash and correlation ID; redact secrets; ship to the SIEM.
MCP09 Shadow MCP serversRegistry of approved servers, egress policy that blocks the rest, periodic scans of developer machines and CI images.
MCP10 Context injection & over-sharingScope context per task and per tenant; do not let one session's retrieved data persist into another's.

Hardening, layer by layer

Pin tool definitions, or accept rug pulls

Rug pulls work because a user approves a tool once and never looks again. The fix is mechanical: hash the definition at approval and verify before each call.

import hashlib, json

def tool_fingerprint(tool: dict) -> str:
    canonical = json.dumps(
        {"name": tool["name"],
         "description": tool.get("description", ""),
         "inputSchema": tool.get("inputSchema", {})},
        sort_keys=True, separators=(",", ":"), ensure_ascii=False)
    return hashlib.sha256(canonical.encode()).hexdigest()

# At approval time: store fingerprint per (server, tool).
# Before every execution:
if tool_fingerprint(live_tool) != PINNED[server_id][live_tool["name"]]:
    raise ToolDefinitionDrift(server_id, live_tool["name"])   # fail closed, re-prompt the human

Fail closed. A tool whose description changed since approval is a new tool, and it needs a new decision from a person — not a silent retry.

Sandbox local servers, and mean it

A local MCP server is a binary running with the client's privileges. The spec's guidance for one-click configuration is unambiguous: show the exact command untruncated, warn that it runs with client privileges, and sandbox it with minimal default access to filesystem and network.[spec-sec] Prefer stdio for local servers, because it limits reachability to the client process. If you must use HTTP locally, bind to 127.0.0.1 — never 0.0.0.0 — require a token, and validate the Host header on every request.[owaspcs]

# Containerised MCP server: read-only root, no new privileges, dropped caps,
# one bind mount, no network unless the server genuinely needs it.
docker run --rm -i \
  --network none \
  --read-only --tmpfs /tmp:rw,noexec,nosuid,size=64m \
  --cap-drop ALL --security-opt no-new-privileges \
  --pids-limit 128 --memory 512m --cpus 1 \
  --user 10001:10001 \
  -v "$PWD/workspace:/workspace:ro" \
  ghcr.io/example/mcp-server@sha256:<digest>   # digest, never :latest

The @sha256: digest is the supply-chain control, not a stylistic preference. A tag can be re-pointed; a digest cannot. If you already run workloads on Kubernetes, the same reasoning about blast radius applies here — and so does the advice in when not to use Kubernetes: a container per MCP server is worth it, a control plane per MCP server is not.

Control egress — SSRF is in the discovery path

This one is easy to miss because it targets the client, not the server. During OAuth discovery the client fetches URLs supplied by the server: the resource_metadata URL from WWW-Authenticate, the authorization_servers entries, then the endpoints from AS metadata. A malicious server can point any of those at http://169.254.169.254/ and read cloud instance credentials through your client.[spec-sec]

Clients SHOULD require HTTPS outside loopback, block private and link-local ranges (10/8, 172.16/12, 192.168/16, 127/8, 169.254/16, fc00::/7, fe80::/10), apply the same rules to every redirect hop, and route server-side deployments through an egress proxy. The spec explicitly warns against hand-rolling IP validation — octal, hex and IPv4-mapped IPv6 encodings defeat most custom parsers — and against DNS rebinding between check and use.[spec-sec][owaspssrf]

Coding agents in CI: the highest-value target you own

An agent in CI holds repository write access and workflow secrets, and its input is whatever a stranger typed into an issue. That is the configuration the Black Hat work attacked. The following is the shape that survives review.

# Untrusted trigger → minimum privilege, no secrets, explicit human gate.
on:
  issues:
    types: [opened]

permissions:
  contents: read          # never write on an untrusted trigger
  issues: read
  id-token: none

jobs:
  triage:
    runs-on: ubuntu-latest
    environment: agent-sandbox        # required reviewers for anything privileged
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@<commit-sha>   # pin actions by SHA, not by tag
        with: { persist-credentials: false }

      # Untrusted text arrives as a file, never interpolated into a shell command.
      - run: printf '%s' "$ISSUE_BODY" > /tmp/untrusted.md
        env:
          ISSUE_BODY: ${{ github.event.issue.body }}

      # Agent runs last in the job, read-only sandbox, no repo token in env.
      - run: agent-cli review --input /tmp/untrusted.md --sandbox read-only --no-network
  • Never interpolate ${{ github.event.* }} into a run: string. Pass it through an environment variable or a file.
  • Repository instruction files are untrusted input. OpenAI's own guidance now says so, after two Codex passes sharing a checkout let the first write the second's instructions.[codex]
  • Run the agent last. The same guidance warns it may otherwise leave files behind for privileged steps that follow.[codex]
  • Separate passes into separate jobs with separate checkouts, so one pass cannot write the next one's inputs.
  • Patch the harness. Gemini CLI 0.39.1, run-gemini-cli 0.1.22, Claude Code 2.1.163 — then audit every workflow an outside user can trigger.

Audit: make every tool call reconstructable

OWASP lists missing telemetry as its own risk, and it is the one that turns a contained incident into an unbounded one.[owasp10] The minimum record per invocation: authenticated caller identity, server and tool, the tool fingerprint that was verified, full parameters with secrets redacted, the decision path (auto-approved, policy-allowed, human-approved), outcome, latency and a correlation ID that ties the whole agent run together. Alert on first-seen tools, scope elevations and instruction-like patterns in tool output.

Two structural decisions make this tractable at more than a handful of servers. First, put a gateway in front: one place that enforces allowlists, per-server isolation, egress policy and logging beats the same policy re-implemented in every host. Second, keep the boring answer where you can — the argument in boring cloud architectures applies exactly: fewer moving parts, fewer trust boundaries, fewer places for a confused deputy to hide. The same restraint pays off when you are assembling an agent stack in the first place.

A verification pass you can run today

Nine checks. If any fails, you have found real work.

# 1 — Unauthenticated request must be refused with a usable pointer
curl -sS -o /dev/null -w '%{http_code}\n' https://mcp.example.com/mcp        # expect 401

# 2 — Protected Resource Metadata exists and names an authorization server
curl -sSf https://mcp.example.com/.well-known/oauth-protected-resource | jq -e '.authorization_servers[0]'

# 3 — A token minted for a DIFFERENT resource must be rejected
curl -sS -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $TOKEN_FOR_OTHER_AUDIENCE" \
  https://mcp.example.com/mcp                                                # expect 401, never 200

# 4 — Plain HTTP must not be accepted for a remote server
curl -sS -o /dev/null -w '%{http_code}\n' http://mcp.example.com/mcp        # expect redirect or refusal

# 5 — Local servers must not listen on every interface
ss -ltnp | grep -E '0\.0\.0\.0|\[::\]' || echo 'ok: nothing bound wide open'

# 6 — Tool definitions must match what was approved
mcp-inspect list-tools --server internal | sha256sum   # compare against the pinned value

# 7 — Container images must be pinned by digest, not by tag
grep -rEn 'image:\s*\S+:(latest|main|v?[0-9]+)\s*$' deploy/ && echo 'FAIL: unpinned images'

# 8 — No untrusted event data interpolated into shell steps
grep -rn 'github\.event\.\(issue\|comment\|pull_request\)' .github/workflows/ | grep 'run:'

# 9 — Harness versions are at or above the patched releases
gemini --version; claude --version

Check 3 is the one that most often fails on a first pass. It is also the one the specification is least ambiguous about.

What to do this week

MCP is worth adopting. The protocol removes a genuine class of integration toil, and the 2025-11-25 revision moved real security decisions into the specification instead of leaving them to folklore. But it ships as a capability, not as a control plane. The controls that matter are the ones you already know from identity and supply-chain work, applied to a boundary most teams have not drawn yet.

In priority order: patch the harnesses; make audience validation non-optional; pin tool definitions and container digests; sandbox local servers and close egress; and put a human gate in front of anything destructive. The organisations that get burned in the next twelve months will not be the ones that adopted MCP. They will be the ones that adopted it and left the harness to defaults.

Frequently asked questions

Is MCP secure by default?

No, and the specification does not claim to be. Authorization is optional for MCP implementations, and the spec leaves sandboxing, tool integrity and network policy to whoever builds the host, client or server. A default install of a local MCP server runs with the same privileges as the client that launched it.

What is tool poisoning in MCP?

Tool poisoning is when an attacker manipulates the tools an agent depends on so the model behaves differently. OWASP groups three sub-techniques under it: rug pulls, where a trusted tool's description is changed after approval; schema poisoning, where the interface definition itself misleads the model; and tool shadowing, where a malicious server's description alters how the agent uses tools from a different, trusted server. Pinning a hash of name, description and input schema, and verifying it before each call, closes the first two and detects the third.

Do I have to use OAuth for my MCP server?

For remote HTTP transports, effectively yes. The specification says HTTP-based implementations should conform to its OAuth 2.1 model: the server acts as a resource server, must implement RFC 9728 Protected Resource Metadata, and must validate that presented tokens were issued for it. Local stdio servers are told to take credentials from the environment instead, but they still need sandboxing and consent controls.

What is token passthrough and why is it forbidden?

Token passthrough is accepting a token from a client without checking it was issued to you, then forwarding it unchanged to a downstream API. The MCP specification forbids it explicitly. It breaks audit trails, bypasses controls that depend on token audience, and lets anyone with a stolen token use your server as an exfiltration proxy. If your MCP server calls an upstream API, it must obtain its own token for that API.

Is a Model Context Protocol vulnerability being exploited in the wild?

As of publication, neither CVE-2026-12537 nor CVE-2026-54316 appears in CISA's Known Exploited Vulnerabilities catalogue, and public reporting does not show either chain used against a target. A public reproduction repository for the Claude Code issue has existed since June 2026, so treat patch latency, not exploit availability, as your limiting factor.

How do I stop a malicious MCP server reaching my internal network?

The exposure is in the OAuth discovery path: the client fetches URLs the server supplies, so a malicious server can point them at cloud metadata endpoints or internal services. Require HTTPS outside loopback, block private and link-local IP ranges, apply the same validation to every redirect hop, and route server-side clients through an egress proxy. Do not hand-roll the IP checks — encoding tricks defeat most custom parsers.

What are Client ID Metadata Documents (CIMD)?

CIMD is a client-registration mechanism added as a recommended default in the 2025-11-25 MCP revision under SEP-991. The client_id is an HTTPS URL that resolves to a JSON document describing the client, including its redirect URIs. The authorization server fetches and validates it on demand, which avoids both hardcoded client IDs and the pile of throwaway registrations that Dynamic Client Registration produces — and removes one ingredient of the confused-deputy attack.

Sources and further reading

Specification text, IETF RFCs, OWASP project material, the NSA cybersecurity information sheet and first-party vendor advisories used for this article.

  1. Model Context Protocol — Authorization (specification 2025-11-25)
  2. Model Context Protocol — Security Best Practices
  3. Model Context Protocol — Key Changes, revision 2025-11-25
  4. SEP-991 — OAuth Client ID Metadata Documents for MCP
  5. RFC 9728 — OAuth 2.0 Protected Resource Metadata
  6. RFC 8707 — Resource Indicators for OAuth 2.0
  7. RFC 9700 — Best Current Practice for OAuth 2.0 Security
  8. OAuth 2.1 — draft-ietf-oauth-v2-1-13
  9. OWASP MCP Top 10 (beta, v0.1)
  10. OWASP Cheat Sheet Series — MCP Security
  11. OWASP Cheat Sheet Series — SSRF Prevention
  12. NSA Artificial Intelligence Security Center — CSI: MCP Security Design Considerations for AI-Driven Automation
  13. GHSA-fg94-h982-f3mm — Out-of-Band Data Exfiltration via Pre-Approved HuggingFace Domain in WebFetch
  14. NVD — CVE-2026-54316
  15. GHSA-wpqr-6v78-jr5g — Google run-gemini-cli security advisory
  16. NVD — CVE-2026-12537
  17. OpenAI — codex-action security guidance

Was this useful?