Atlas survey

Security & Auth for MCP Servers: What to Teach Builders in 2026

The MCP auth spec makes your server an OAuth 2.1 resource server; the real danger is the agent layer — poisoned tool descriptions and injected tool results. Teach both.

18 sources ~7 min read mcp · security · oauth · authorization · prompt-injection · supply-chain

TL;DR for the session. Two layers to teach. (1) Protocol auth is solved-ish: since the 2025-06-18 spec your MCP server is an OAuth 2.1 resource server — it validates externally-issued tokens, advertises its auth server via RFC 9728 protected-resource metadata, enforces audience binding (RFC 8707), and MUST NOT pass tokens through to upstream APIs [1][3]. (2) The auth spec does not protect you from the agent layer — the live 2026 threat is tool poisoning and prompt-injection-via-tool-results, where a malicious tool description or returned result hijacks the model. Anchor the session on: validate token audience, never proxy tokens, least-privilege scopes, and treat every tool description and tool result as untrusted input [5][2].

This audience (Java/.NET devs on the official TypeScript SDK) already knows OAuth. Spend session time on what’s MCP-specific: the resource-server reclassification, audience binding, and the agent-layer threats OAuth was never designed to stop.

Part 1 — The MCP authorization spec

The model: your server is an OAuth 2.1 resource server

A protected MCP server is an OAuth 2.1 resource server — it accepts and validates access tokens, it does not issue them. The authorization server is a separate role (your IdP: Entra, Auth0, Keycloak, Okta) [1]. This is the single most important framing for the session: you are not building an OAuth server, you are protecting a resource.

Hard requirements from the current spec, in the order a request hits them [1]:

# Requirement Who Level
1 Serve RFC 9728 protected-resource metadata (/.well-known/oauth-protected-resource) with an authorization_servers field server MUST
2 On missing/invalid token, return 401 with a WWW-Authenticate header pointing at resource_metadata (and ideally a scope hint) server MUST
3 Use OAuth 2.1 + PKCE S256; refuse to proceed if the AS doesn’t advertise code_challenge_methods_supported client MUST
4 Send the resource parameter (RFC 8707) on every authz + token request, naming the target MCP server client MUST
5 Validate the token audience — accept only tokens issued for this server; reject everything else server MUST
6 Never pass the inbound token through to an upstream API; mint a separate upstream token server MUST NOT

Rules 5 and 6 are where most homegrown servers fail, and they’re the two that close confused-deputy and audience-confusion holes (Part 2).

What changed across spec revisions

The auth story has rewritten itself three times — attendees who read a 2025 tutorial may be implementing the wrong thing.

Revision What it established
2025-03-26 First auth flow. MCP server was the OAuth server; relied on hardcoded /authorize, /token, /register endpoints [3]
2025-06-18 The big one: server reclassified as resource server, AS split out. Mandated RFC 9728 metadata + RFC 8707 resource indicators; explicitly forbade token passthrough [3]
2025-11-25 Formalized the resource-server classification; tightened resource-indicator requirements; added Client ID Metadata Documents as the preferred client-registration path [4]

Takeaway to say out loud: if a tutorial has your MCP server issuing tokens or exposing /token, it predates June 2025 — don’t follow it [3].

In the official TypeScript SDK

Concrete so the session lands. With @modelcontextprotocol/sdk [11] ⭐ 13k (Jun 2026), resource-server mode is roughly [12][13]:

  • Run StreamableHTTPServerTransport behind Express.
  • Mount app.get("/.well-known/oauth-protected-resource", ...) as an anonymous endpoint (RFC 9728).
  • Wrap MCP routes in requireBearerAuth({ verifier }) — it validates the Bearer token via an OAuthTokenVerifier and attaches AuthInfo to req.auth.
  • In the verifier, check the aud claim equals your canonical server URI (don’t skip this — the middleware won’t do audience binding for you).
  • Read authInfo.scopes inside every tool handler and authorize per-operation; a valid token is not authorization.

stdio transport is exempt from the spec — it reads credentials from the environment, so local servers don’t do OAuth [1].

Part 2 — The threat landscape

OAuth secures the transport. It does nothing about the fact that an LLM reads attacker-influenced text — tool descriptions and tool results — and acts on it. This is the part of the session that earns its slot.

Threat → mitigation table

Threat What happens Mitigation to teach
Tool poisoning Hidden instructions in a tool description (model sees full text, user sees a summary) tell the agent to read SSH keys / files and exfiltrate them [5] Render full descriptions to users; pin tools by checksum/version so an approved tool can’t silently change; treat descriptions as untrusted [5]
Shadowing / cross-server hijack A malicious server’s description rewrites how the agent uses a trusted server’s tools (e.g. redirect all emails) — attacker tool never appears in the user log [5] Isolate servers; dataflow controls between them; don’t co-mount untrusted + sensitive servers in one agent [5]
Prompt injection via tool results Returned data (ticket text, web page, DB row) carries instructions the model obeys — e.g. Supabase SQL-injection via a support ticket [8] Sanitize/validate tool outputs before they reach the model; mark them as data, not instructions; never run tools with standing high privilege [8]
Confused deputy Proxy server with a static upstream client ID + dynamic client registration + a consent cookie lets an attacker reuse consent and steal the auth code [2] Per-client consent stored server-side, checked before forwarding upstream; exact redirect_uri match; single-use state; __Host- consent cookies [2]
Token passthrough / audience confusion Server forwards the client’s token upstream, or accepts a token minted for another service — bypasses rate limits, breaks audit trail, enables lateral use [2] Validate aud; accept only tokens issued for this server; mint a separate upstream token — never proxy the inbound one [1][2]
Over-broad scopes Server publishes every scope in scopes_supported; client requests files:*, db:* up front → huge blast radius on a stolen token [2] Minimal baseline scope (e.g. mcp:tools-basic); step-up via WWW-Authenticate scope="…" 403 challenges; no wildcard/omnibus scopes [2]
Supply chain (3rd-party servers) Installing an untrusted server = arbitrary code with client privileges; malicious startup commands, rug-pulls (CVE-2025-54136) [2][15] Vet provenance; pin versions/checksums; sandbox local servers (containers, restricted FS/network); one-click installs MUST show the exact command + consent [2]
SSRF via discovery Malicious server returns resource_metadata/AS URLs pointing at 169.254.169.254 or internal hosts; client fetches them, leaks cloud creds [2] Enforce HTTPS; block private/link-local IP ranges; use an egress proxy; pin DNS between check and use [2]
Session hijacking Guessable session ID lets an attacker impersonate a user or inject events across stateful HTTP servers [2] Non-deterministic session IDs; never use sessions for auth — verify every request; bind session to <user_id>:<session_id> [2]

The canonical demo to show

The WhatsApp exfiltration (Invariant Labs, April 2025) is the clearest single story: a benign-looking trivia/addition MCP server ships a tool description with hidden instructions; the agent — also connected to a trusted whatsapp-mcp server — is steered into reading the full chat history and exfiltrating it through ordinary message traffic, which DLP misses because it leaves via a legitimate channel [6][18]. Reproducible PoCs are public [7] ⭐ 195 (Jun 2026). OWASP now tracks this class as MCP Tool Poisoning, an indirect prompt-injection attack [14].

2026 CVEs worth naming (supply chain is real)

Early 2026 saw ~30 MCP CVEs in 60 days [16]. The ones that make the supply-chain point land:

CVE Component Issue
CVE-2025-49596 (CVSS 9.4) MCP Inspector < 0.14.1 No auth between Inspector client and proxy → unauthenticated RCE on a dev machine via a malicious website (0.0.0.0-day + CSRF chain); fixed 0.14.1 [9]
CVE-2025-6514 mcp-remote OS command injection → RCE on client via a malicious authorization_endpoint [10]
CVE-2025-53109/53110 Anthropic Filesystem MCP Sandbox escape / symlink bypass → arbitrary file access [10]
CVE-2025-54136 (MCPoison) MCP tool config Approved tool definition silently swapped after trust (rug pull) [15]

The lesson for attendees: the tooling around your server (Inspector, mcp-remote, third-party servers) is attack surface too — pin versions, read advisories, sandbox local servers.

What every session attendee should walk away with

  1. Your MCP server is an OAuth 2.1 resource server — validate tokens, advertise metadata (RFC 9728), don’t issue tokens [1].
  2. Validate the audience (RFC 8707) and never pass tokens through — these two close the confused-deputy/audience-confusion class [1][2].
  3. Least-privilege scopes, step-up via 403 challenges, no wildcards [2].
  4. Treat tool descriptions and tool results as untrusted input — this is the OAuth-can’t-help layer, and the one your demo should hit [5][8].
  5. Sandbox and pin third-party/local servers; check authInfo.scopes in every tool handler [2][12].

Citations · 18 sources

Click the Citations tab to load…