The MCP spec describes a protocol, not a security posture. Most production deployments shipped with a static secret, no identity propagation, and error messages that leak internals. Five enforcement layers — executable before the next incident review.
MCP server security is the problem nobody owns. The platform team shipped the servers. The AI team wired up the tools. Security got a five-minute demo, signed off on a PoC, and that PoC quietly became production six weeks later. Nobody explicitly chose this configuration. It is the default state of any system without an owner.
Endor Labs analyzed 2,614 MCP implementations and found that 82% use file system operations prone to path traversal, 67% use APIs related to code injection, and 34% use APIs susceptible to command injection.[6] The first 60 days of 2026 produced 30 publicly tracked CVEs against MCP servers alone.[11] That is not a theoretical threat surface. It is a production reality for any team that shipped fast and called it done.
This guide is for the platform engineer or security lead who inherited a deployment they did not design. It assumes you know what MCP is. It covers five places production deployments are currently exposed: static client secrets, missing identity propagation, unguarded gateways, tool poisoning via manipulated descriptions, and error messages that hand reconnaissance to whoever asks.
The last audit we ran on an inherited deployment found the static secret committed to git — in .env.example, four months earlier. GitHub search surfaced it in under 30 seconds. Three production services were using that secret. None had rate limiting. The PoC-to-production gap had been six weeks. Audit before you change anything. The findings are usually worse than the architecture diagram suggests.
Why static secrets fail: no expiry, no identity, no rotation path
JWT validation against your IdP's JWKS endpoint — full TypeScript implementation
Identity propagation through tool chains using AsyncLocalStorage
Gateway controls that belong at the gateway, not in application code
Tool poisoning: what it is, why the MCP trust model makes it structural, and how to defend it
Input validation and output sanitization for tool arguments
Error semantics that don't hand your internals to the caller
A 6-step migration that avoids a hard cutover
A pre-deployment checklist with 15 verifiable states
Source: Endor Labs analysis of MCP implementations, 2025
Source: agent-wars.com MCP security tracker
Source: npm scan analysis, Composio research
The auth model most MCP servers shipped with assumes a trust boundary that no longer exists.
The original MCP transport assumed a trusted execution environment — a local subprocess talking over stdio. When teams moved to HTTP/SSE transports for multi-client deployments, they needed some form of auth and reached for the path of least resistance: a shared static secret in an Authorization header.
Here is what that pattern looks like in TypeScript MCP servers in the field today:
The IdP issues. The gateway validates. The MCP server trusts the verified context — and never the request body.
The June 2025 MCP spec update formalized what good deployments were already doing: MCP servers are OAuth 2.1 resource servers, not authorization servers.[8] They validate tokens. They do not issue them. Your IdP — Okta, Azure AD, Google Workspace, Auth0 — is the authority. The MCP server receives a verified user context and trusts it, without doing crypto itself.
The spec also mandates Resource Indicators (RFC 8707): tokens must name the specific MCP server they're issued for. A token minted for your analytics server cannot be replayed against your code-execution server. Without this check, token substitution attacks work against any deployment that only verifies the signature.[8]
The structural shift: the MCP server stops being an auth authority and becomes an auth consumer.
Working TypeScript. JWT validated against the IdP. Issuer, audience, and resource indicator checked — not just the signature.
Authentication answers who connected. Propagation answers who the tool acts as. They are different problems — most deployments only solve the first.
Authentication at the transport layer answers "who connected." Identity propagation answers "who should this tool act as." Different problems. Most MCP deployments solve the first and ignore the second.
Consider a tool that queries your data warehouse or calls an internal API. If the MCP server makes that downstream call using its own service account, the tool inherits service-level access — not the connecting user's access. The sales rep asking for customer data and the sysadmin asking for customer data get the same result. That is not a permissions model. That is a permission bypass.[1]
An npm scan found 63.5% of MCP packages expose destructive operations without requiring human confirmation.[10] Identity propagation is what makes those operations auditable — it's the difference between a log entry that says "data exported" and one that says "data exported by alice@company.com at 14:32 with read-only scope." Without propagation, you have an audit trail with no subjects.
The fix is forwarding the verified user context as an impersonation credential or a forwarded token. The downstream system enforces row-level access against the user, not against the MCP server's principal:
The LLM reads every character of every tool description. Attackers who control a description control the agent.
Tool poisoning is the most structurally novel MCP attack vector — and the one most deployments have not addressed at all. The mechanism: an attacker plants malicious instructions inside the description, parameter schema, or return value of an MCP tool. The user never sees the injected text. The LLM reads every character, and once the poisoned description enters the model's context, it can hijack agent behavior across the entire session.[7]
The Invariant Labs disclosure in April 2025 demonstrated this concretely: tool descriptions that appeared benign to a human reviewer contained invisible Unicode characters with instructions to exfiltrate data from other tools in the same session. CVE-2025-54136 (MCPoison, disclosed August 2025) extended this to the rug-pull pattern — commit a benign config, get it approved once, then swap the payload after approval.[11]
Three surfaces for tool poisoning:
The June 2025 ETDI (Enhanced Tool Definition Interface) paper proposes cryptographic binding of tool definitions to signed JWTs — any change to a tool's definition invalidates the existing signature, making rug pulls detectable.[9] That is not yet widely deployed. Until it is, the controls available to you are architectural.
| Attack surface | Mechanism | Current best defense |
|---|---|---|
| Tool description poisoning | Malicious instructions hidden in tool descriptions; LLM executes them as trusted context | Registry-only tool sources; signed tool manifests; deny third-party servers by default |
| Output poisoning | Tool return values contain prompt injection that redirects subsequent agent steps | Treat tool output as untrusted data, not trusted instructions; output boundary markers |
| Rug pull / config swap | Approved tool config replaced with malicious payload after trust is established | Pin tool versions in config; ETDI cryptographic binding (RFC 8707 approach); diff tool definitions in CI |
| Tool squatting | Attacker publishes a tool with a name similar to a trusted one; client loads the wrong one | Explicit allowlist of approved tool IDs; deny any tool not in the list; verify publisher identity |
| Cross-server data leakage | Injected tool grabs context from another tool in the same session | Separate read and write tools onto separate MCP servers; limit session-level context sharing |
MCP tool arguments flow directly from LLM output to your server. Treat them like any other untrusted input.
82% of MCP implementations use file operations that are susceptible to path traversal without input sanitization.[6] CVE-2025-68144 in the Anthropic mcp-server-git package passed user-controlled arguments directly to the Git CLI without sanitization, enabling argument injection.[11] CVE-2025-53967 in the Framelink Figma MCP server (600,000+ downloads) constructed curl commands from unsanitized user input.[6]
The common thread: developers treat MCP tool arguments as application-level input with implicit trust, not as external attacker-controlled data. They are external attacker-controlled data. A user's prompt goes to the LLM, the LLM generates tool arguments, and those arguments arrive at your server. Every hop adds an opportunity for injection — including prompt injection attacks in the data the LLM was reading before it generated the call.
Rate limits, IP allowlisting, request size, TLS — these belong at the gateway. Application code is the wrong layer.
The MCP server is a tool execution runtime. It is not a security appliance. Asking it to handle rate limiting, DDoS protection, and geographic restrictions is the wrong layer. Those controls belong at the gateway — Kong, AWS API Gateway, Nginx, Cloudflare, take your pick.
Here is the minimum the gateway must enforce before a request ever touches your MCP process:
| Control | Failure mode it stops | Gateway implementation |
|---|---|---|
| JWT validation at edge | Unauthenticated requests reaching the server process at all | IdP JWKS endpoint — most gateways ship a native OIDC plugin |
| Per-user rate limiting | One compromised token hammering downstream tools until billing notices | Key by JWT sub claim, not IP — the identity is the unit of enforcement |
| Request body size cap | Tool arguments smuggling huge payloads through unbounded readers | Nginx: client_max_body_size 1m; Kong: request-size-limiting plugin |
| Tool allowlist by scope | Read-scoped tokens reaching write tools because routing trusts the prompt | Route matching plus JWT scope claim validation at gateway level |
| Audit log forwarding | Tool calls that left no trace because the SIEM hookup was post-hoc | Forward to SIEM before the request hits the server, not after |
| TLS termination plus HSTS | Plain-HTTP SSE traffic intercepted on an untrusted network | Enforce TLS 1.2+ minimum; HSTS with one-year max-age |
| Tool server allowlist | Third-party MCP servers registering themselves and getting routed to | Explicit allowlist of approved server origins; deny by default |
Verbose errors helpful in development become a free internal-system tour in production.
MCP error handling defaults to transparency. Helpful in development. Dangerous in production. Stack traces, database error messages, upstream API responses, internal service names — all of it ends up in tool call responses if you do not explicitly sanitize.
The MCP protocol defines a structured error format in the JSON-RPC layer. Use it. Map every internal error code to a safe external one, log the real one to your aggregator, and never let the original message reach the caller:
Default-drift deployments versus hardened ones — the same incident scenarios, very different outcomes.
Static secret in .env. Rotated never. Leaks eventually — GitHub search finds it in 30 seconds.
Every client shares one identity. No per-user audit trail exists.
Tool calls run as the service account. Every caller gets service-level blast radius.
Tool descriptions accepted from any source. Poisoned descriptions execute without detection.
Tool arguments passed to CLI commands unsanitized. Path traversal and argument injection are live.
Errors expose stack traces, DB messages, internal hostnames.
Long-lived SSE with no timeout. Zombie sessions accumulate until restart.
No rate limit. One token can hammer every tool until billing notices.
Short-lived JWTs from the IdP. Auto-expire. RFC 8707 audience binding. MFA enforced upstream.
Every tool call bound to a verified user identity. The audit trail writes itself.
Tools forward user credentials. Downstream access matches the user, not the server.
Tool sources restricted to an explicit allowlist. Descriptions pinned by version hash.
All tool arguments validated against strict Zod schemas before execution. Path resolution clamped to base dir.
Sanitized error codes on the wire. Internal context lives in the log aggregator.
Gateway closes idle connections automatically. Stale sessions die before they matter.
Per-user rate limiting by sub claim. Server and downstream APIs both protected.
Each item is a verifiable state. If you can't tick it, the next security review will find it.
Dual-auth middleware buys a window. Use it — then close it on a date you publish in advance.
Before touching auth, map every client connecting to your MCP server. Log the Authorization header value (hashed, not plain) and the User-Agent for two weeks. You need to know what will break before you break it. Skipping this step is how migrations cause incidents that look like the auth change but are really an unowned client nobody knew existed.
In your IdP — Okta, Azure AD, Google Workspace — create an application registration for the MCP server. Define scopes that map to tool categories: mcp:read for query tools, mcp:write for mutation tools. Define the canonical resource URI (used as the OAuth audience). Wrong scopes here means rewriting the routing layer later.
Accept both the old static secret and valid JWTs simultaneously during the migration window. Clients migrate on their own schedule. No hard cutover. Set a removal date in advance — three months is realistic for internal tooling. The deadline is the enforcement; the dual-path is the runway.
Human-facing MCP clients — Claude Desktop, VS Code extensions, internal chat integrations — benefit most from SSO. Users authenticate with existing corporate credentials. Use OAuth 2.0 PKCE for these clients — never the authorization code flow without PKCE, which is vulnerable to authorization code interception.
Automated pipelines and CI systems calling MCP tools programmatically should use OAuth 2.0 client credentials — not the authorization code flow. Each service gets its own client ID and secret stored in your secrets manager (Vault, AWS Secrets Manager — never .env), and its own scope grants. One service compromised does not compromise the others.
Once dual-auth logging shows zero static-secret requests for a week, remove the fallback path. Rotate the old secret out of every environment config and secret store. Document the removal in the runbook so the next on-call does not look for it during an incident. The migration is not done until the old code path is gone.
You cannot inspect what you did not write. Route it through your gateway anyway.
Third-party MCP servers you do not control introduce a trust problem that authentication alone cannot solve. A vendor's server can have correct auth and still be poisoning tool descriptions, retaining tool call data, or exposing its own supply chain vulnerabilities.
Three questions to ask every vendor before connecting their MCP server to internal systems:
If they cannot answer all three, treat their server as untrusted infrastructure. That means: route it through your gateway so you control the auth layer, enforce rate limiting, capture audit logs even when the vendor's server does nothing with user identity, and do not route internal data through their server until they can answer those questions.
For internally hosted third-party servers, the npm supply chain is an additional surface. The first in-the-wild malicious MCP server (postmark-mcp, disclosed September 2025) was a backdoored npm package.[11] Pin your npm dependencies. Use a lockfile. Run npm audit in CI. None of this is MCP-specific — but the MCP ecosystem is young enough that dependency hygiene is worse than in mature ecosystems.
Even if the vendor claims strong auth, your gateway is the control point for rate limiting, audit logging, and identity context. Bypass it for no vendor.
No third-party MCP server should be reachable from your environment unless it appears in a maintained allowlist with a named owner and a review date.
MCP packages are npm packages. The same supply chain risks apply. Lockfiles, npm audit, and a dependency review SLA belong in your onboarding checklist.
Sandbox or test data only until the vendor can demonstrate a third-party audit covering the MCP deployment. This is the same bar you apply to any SaaS integration.
The MCP spec doesn't mandate OAuth — do I really need this?
The spec as of June 2025 does mandate OAuth 2.1 for HTTP-based deployments. Before that update, it described the protocol without prescribing auth patterns — because the spec is not your auditor. Running MCP in production without scoped tokens is the same logic as running an internal REST API with no auth because it is "only internal." Static secrets are fine for a local developer tool. They do not belong in multi-user deployments touching real data and real upstream systems.
We use stdio transport, not HTTP/SSE. Does this apply?
For local stdio deployments where the MCP server runs as a subprocess of a single trusted client, static secrets are mostly a non-issue — there is no network surface. The concerns here apply to HTTP/SSE transports used for multi-client or remote deployments. If your deployment moved from stdio to HTTP for scale and nobody re-evaluated auth at the same time, this is the compliance gap you need to close.
How do we handle MCP clients that don't support OAuth yet?
Dual-auth middleware (step 3 of the migration path) buys a window without a hard cutover. Set the deadline at three months and communicate it. If a client cannot implement OAuth in that window, it does not have access to production tools touching sensitive data. The deadline is the enforcement. Everything before it is runway.
What is tool poisoning and how serious is it really?
Tool poisoning is serious enough to have generated named CVEs (CVE-2025-54136) and real-world incidents in 2025. The mechanism: tool descriptions enter the agent's context window as trusted content. An attacker who controls a description — through a malicious third-party server, a compromised npm package, or a rug pull — can inject instructions the LLM acts on without the user seeing them. The primary defense is architectural: restrict which tool sources your environment accepts, separate read and write tools, and validate all tool output before acting on it.
What about tool-level access control beyond OAuth scopes?
OAuth scopes handle coarse-grained access — read versus write, tool category versus tool category. For fine-grained access — specific customers a user can query, specific repos a token can reach — you need attribute-based access control inside the tool itself. The user context flowing through AsyncLocalStorage makes this possible: each tool checks user groups and enforces row-level rules against its own data source. The scope routes the request. The tool decides what the user is allowed to see.
We inherit user tokens from upstream and forward them. Is that safe?
Token forwarding is the right pattern for preserving identity through tool chains — but only if downstream services validate against the same IdP. Forwarding a token to a service that does not validate it just moves the unauthenticated call one hop downstream. Verify also that the token's scope is not broader than what the next service expects. Both ends of the hop need to enforce. If only one side checks, the chain has a hole.
What's the minimum viable hardening if we can't do everything at once?
In priority order: (1) Replace the static secret with JWT validation — this closes the credential leak risk immediately. (2) Add per-user rate limiting at the gateway keyed by sub claim — this limits blast radius from a compromised token. (3) Validate tool arguments with strict schemas before passing them to any filesystem or CLI operation — this blocks the most common CVE class in MCP servers. Everything else matters, but those three close the highest-probability failures first.
MCP server security does not require a rewrite. The protocol is sound. It shipped without prescribing auth patterns because that is the deployer's job. The problem is that most deployers copy the quickstart, ship it, and move on.
The five controls in this guide — token-based auth with RFC 8707 audience binding, identity propagation, gateway hardening, tool poisoning defenses, and structured error semantics — close the bulk of the attack surface for a typical enterprise MCP deployment. None of them require changing the protocol or waiting for a roadmap.
The teams that close these gaps now spend their next security review explaining a well-reasoned architecture. The teams that do not spend it explaining an incident they could see coming.
The MCP specification has evolved rapidly since its November 2024 GA. The June 2025 update formalized OAuth 2.1 with RFC 8707 Resource Indicators and RFC 9728 Protected Resource Metadata as requirements for HTTP-based servers. The authentication patterns in this guide are compatible with the 2025-11-05 specification version. Verify against the current spec at modelcontextprotocol.io before implementation.
Why production inference bills always exceed estimates — and the Finance-Engineering governance framework for per-agent budgets, model routing, context compression, and cost forecasting without capability degradation.
46% of AI proofs of concept never ship. The gap is not technical. It is structural: PoC culture rewards experimentation and punishes shipping. A 90-day decision gate, an operational owner, and an incentive rewrite — or pilot purgatory wins again.
Launches get conference talks. Retirements get archived repos and live credentials. Five sequential phases — audit, extract, shadow, communicate, shut down — and the security blast radius when you skip any of them.