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
StreamableHTTPServerTransportbehind 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 anOAuthTokenVerifierand attachesAuthInfotoreq.auth. - In the verifier, check the
audclaim equals your canonical server URI (don’t skip this — the middleware won’t do audience binding for you). - Read
authInfo.scopesinside 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
- Your MCP server is an OAuth 2.1 resource server — validate tokens, advertise metadata (RFC 9728), don’t issue tokens [1].
- Validate the audience (RFC 8707) and never pass tokens through — these two close the confused-deputy/audience-confusion class [1][2].
- Least-privilege scopes, step-up via 403 challenges, no wildcards [2].
- 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].
- Sandbox and pin third-party/local servers; check
authInfo.scopesin every tool handler [2][12].