DeepConcepts

Agentic Security / MCP / authorization / delegated credentials

The Agent Confused Deputy

The misconception

That the third-party consent screen is the human checkpoint, so if the user saw it once the flow is authorised. The cookie the authorization server set says 'this user consented to mcp-proxy', and mcp-proxy is the same string for every MCP client that will ever connect. An attacker who dynamically registers a client with redirect_uri=attacker.com gets the consent screen skipped and the authorization code delivered, without the user approving anything. The MCP specification's fix is not more consent screens — it is moving consent to the proxy, keyed per client_id, and refusing to set the state cookie until after that consent.

13 min

The consent screen your user saw said "Allow mcp-proxy to access your Gmail?". It did not say which client was asking, because it could not: every agent client that will ever connect through that proxy arrives at Google as the same OAuth client. The cookie recording that consent is therefore a grant to a string, and the next client to register inherits it.

OAuth 2.0 is the protocol by which a user lets one application act on their behalf at another; the Model Context Protocol (MCP) is the wire format an agent uses to reach tools. Where they meet is a pattern the MCP specification calls an MCP proxy server: a server that offers MCP tools to clients while acting as a single OAuth client to some third-party API. The pattern exists because most third-party authorization servers do not support dynamic client registration, so the proxy has to hold one registered client_id and use it for everyone.

That is a confused deputy: a component with more authority than its callers, which acts on their behalf without being able to tell them apart. The MCP specification lists four conditions that make the attack possible — a static client ID at the third party, dynamic client registration at the proxy, a consent cookie at the third party, and no per-client consent before forwarding — and requires that proxies "MUST implement per-client consent". What is much less widely understood is where that consent is actually enforced, and it is not on the consent page.

Below are both flows, run side by side against the same configuration. On the left, a legitimate client the user really did authorise. On the right, an attacker who registered a client at your proxy and sent your user a link. Every control changes both. The one to reach for first is the one everybody reaches for: the proxy shows its own consent page, keyed by client_id.

The attack flow assumes the legitimate flow already happened, which is what put the third party's consent cookie in the user's browser. Both flows are walked step by step against the same settings; the trace shows where each one stops.

scopes the attacker ends up holding, of 6
the attack stops at
consent screens the human saw during the attack
screens that named the client actually asking
consent screens in the legitimate flow
the route the attacker's link takes
the legitimate flow
Both flows, step by step

the step completed · a human saw something · the step handed something to the attacker. A step with no marker was never reached.

What is real and what is a model. The message sequence is the one in the MCP specification's confused-deputy diagrams, including the detail that the specification's malicious link goes to the third-party authorization server rather than through the proxy. The alternative route, in which the attacker collects a state value from the proxy's /authorize and abandons the consent page, is the bypass the specification describes in the paragraph on when that cookie may be set. The gating rules are the specification's requirements read as conditions. What is modelled is the human: a consent screen that names an unfamiliar client and an unfamiliar redirect target is assumed to be refused, and one that names only the proxy is assumed to be approved.

Tick the proxy's own consent page. The hero number does not move: the attacker still ends up holding 6 scopes, and the attack stops at still reads it does not stop. Read the attack column. Step 2 now says the link goes "through /authorize to collect a state cookie, then around the consent page", and the consent page log line explains it: the proxy stores its state value at /authorize, before anybody approves anything, so the attacker collects one and walks past the page.

Set when the proxy stores the state value to only after the user approves. Still 6. The callback does not compare the value to anything, so recording it changed nothing. Set state validation at the callback to compare the query parameter to the stored value and the hero drops to 0, stopped at the callback's state check.

Now read where it died. Not at the consent page — look at the attack column and notice that the page never appears in it at any setting. At this setting the malicious link goes straight to the third party, exactly as the specification's attack diagram shows. Move when the proxy stores the state value back to at /authorize and the route changes but the outcome does not: the attacker now visits the proxy's /authorize, collects a state value, and abandons the page without approving. The specification names that second route too — setting the state cookie before approval "renders the consent screen ineffective, as an attacker could bypass it by crafting a malicious authorization request". The consent page is where a decision gets made about a client_id; the state check at the callback is the only place that decision is ever consulted. Implementing one without the other produces a system with a consent screen, a consent record, and no consent.

The screen that names the wrong party

Reset to the defaults and untick the third party sets a consent cookie and skips consent on repeat visits. This is the fix that sounds obviously right: if the cookie is what gets skipped, stop skipping. The hero number stays at 6. The consent screens the human saw during the attack readout goes from 0 to 1, and screens that named the client actually asking stays at 0.

The screen renders. It says "Allow mcp-proxy to access your Gmail?" and lists six scopes. It cannot say anything else, because mcp-proxy is the OAuth client — the attacker's client is not a party to this conversation at all, and neither is https://attacker.example/cb. A user looking at that screen has no fact available to them that distinguishes this request from the one they approved last week.

This is worth stating flatly because it inverts the usual instinct about consent. More consent screens is not a safety property. A consent screen is only a control if it names the principal that will end up holding the token, and the entire structure of an OAuth proxy is that it does not. The screen is accurate — mcp-proxy really is the thing that will hold the third-party token — and useless, because what the user needs to decide about is who mcp-proxy will hand it to next.

Now tick the third-party authorization server supports dynamic client registration and set how the proxy identifies itself to the third party to a distinct client_id per MCP client. The hero drops to 0, stopped at the third party's consent screen, and screens that named the client actually asking goes to 1 — the only setting in this simulation where it does. The third party has never seen that client_id, holds no cookie for it, and renders a screen with the attacker's client name on it. That is the root fix, and every other control in this lesson is a way of compensating for not having it.

Why nobody does the root fix

Leave a distinct client_id per MCP client selected and untick the third-party authorization server supports dynamic client registration. Both columns collapse. The legitimate flow reads cannot run, and the attack column says there is no working proxy to hijack — which is true, and is not a security posture. Registering a client_id per MCP client requires the third party to accept dynamic client registration, and most large API providers do not.

That single unticked box is the entire reason this vulnerability class exists. With it on, both problems vanish at once: the legitimate flow completes and the attack is refused by name, with no consent page, no state ceremony and no per-client registry anywhere in the proxy. With it off, none of that is available to you, because it is a property of somebody else's identity provider.

This is why the static client_id proxy pattern gets defended in public as "the only thing that works", and why the specification mitigates the attack rather than forbidding the pattern. It is not a mistake anyone made. It is a structural consequence of an ecosystem where the party that needs per-client identity and the party that issues client identities are different organisations. If you are building the third-party API rather than the proxy, supporting dynamic client registration is the single highest-value security feature you can ship for this class of consumer, and it will not appear on any of your own threat models.

The controls that are working and do not matter

Return to the defaults and set redirect_uri validation at the proxy to exact string match, re-registration required to change. Nothing moves. Set it to none. Nothing moves. The redirect_uri log line says why: the attacker registered https://attacker.example/cb as their own client's redirect target, so it matches exactly, at every strictness setting.

Exact redirect_uri matching is a real and necessary control — the authorization code flow depends on it, and it defends against an attacker who substitutes a redirect target onto somebody else's registered client. It is doing that job here. The attacker simply is not doing that. They registered their own client, honestly, through a documented endpoint, and then used it exactly as registered. Every validation in the request path returns "valid", because every value in the request is valid. The thing that is wrong is upstream of any value: it is which principal the third party thinks it is talking to.

The same is true of the state parameter in its usual role. State and PKCE — Proof Key for Code Exchange, the extension that binds an authorization code to the client that requested it — exist to bind a callback to the browser session that started the flow, defending against cross-site request forgery on the redirect endpoint. In this attack the browser session that started the flow really is the victim's, so that binding holds and proves nothing. What the MCP specification asks for is a second, different use of the same parameter: store the value only after a per-client consent approval, so that a valid state becomes evidence that a specific client_id was approved. Same parameter, different job. That is why "we validate state" is not an answer to this question, and why the two controls have to be reasoned about separately.

Untick the proxy lets MCP clients register themselves dynamically for the one control that does stop the attack outright, at step 1. Then read the registration log line, which is the cost: every new MCP client now needs a human to add it by hand before it can connect at all. For a proxy with three known clients that is a reasonable trade and you should take it. For a public MCP endpoint it removes the thing the endpoint is for.

What the stolen token is worth

Set scopes the proxy requests to a read-only baseline, elevated on challenge, leaving everything else at the defaults. The attack still succeeds — nothing about the flow changed — but the hero drops from 6 to 1.

The MCP security guidance names the default configuration as a mistake in so many words: publishing every possible scope in scopes_supported, using wildcard or omnibus scopes, and bundling unrelated privileges to pre-empt future prompts. The reasoning is that broad scopes expand the blast radius of any token compromise, make revocation disruptive enough that nobody does it, and flatten the audit trail so that a single grant covers every operation. A proxy that asks for everything up front is optimising for never being interrupted, and the price is paid entirely in the incident.

Progressive scoping does not stop the attacker from elevating — the flaw that let the first link through has not moved, and a second click gets a second tier. What it buys is that each elevation is a separate authorization event. If you log scope-elevation events with a correlation id, as the guidance recommends, a compromised client escalating through five tiers leaves five log lines. The same compromise against an omnibus grant leaves one line, which is indistinguishable from a normal first login.

Whatever the stolen token turns out to carry, what it can then do is decided at the API, not at the proxy — which is excessive agency again, from the other end. And cleaning up afterwards means revoking a grant the user never knowingly made, at a provider whose revocation interface is organised around applications the user recognises. They will be looking for the attacker's name in the list. It is not there. What is there is mcp-proxy, which they use every day.

Checking this on a proxy you actually run

Start with one question that has a yes or no answer: does your consent record have a client_id column? Not a session, not a user, not a "connected" boolean — a per-user, per-client row that is written when a human approves and read before any redirect to the third party. If the answer is no, you have the vulnerability regardless of what else you have built, and everything below is premature.

Then check where the state value is written, in the code, on the request path. It must be written after the consent POST handler and before the redirect to the identity provider, and nowhere else. The specific bug to grep for is a state being generated in the GET /authorize handler: that is the version this simulation shows doing nothing. Confirm the callback rejects a missing or unknown state with a hard failure rather than a warning, that the stored value is deleted on use, and that it expires — the guidance suggests ten minutes.

Test it with two browser profiles rather than by reading code. In profile A, complete the flow normally with a real client so the third party's consent cookie exists. Then register a second client at your proxy through your own dynamic registration endpoint, with a redirect_uri pointing at a listener you control, and in the same profile A visit an authorization URL for that second client. If you end up with a code at your listener without having seen a screen that says the second client's name, you have reproduced it. This takes about twenty minutes and is the only evidence that settles the question.

For monitoring, the highest-signal record is one you probably are not keeping: for every MCP authorization code you mint, log the client_id it was minted for, the redirect_uri it was sent to, and whether a consent row existed for that pair beforehand. The third column is the alert. It should be false zero times, and the query that finds a true is the same query that would have found this attack the day it happened. Log dynamic client registrations too, with the redirect_uri hostname, and look at the list weekly — an attacker registering a client is a normal, successful, well-formed API call that leaves no other trace.

Finally, be clear about what class of failure this is. It is not a consent that a tired human clicked through — that is approval fatigue, a different problem with a different fix. This is a consent that was never rendered to anybody, for a decision nobody was asked to make, in a flow where every individual check returned valid. No amount of making the prompts better addresses it, because there was no prompt.

Why this concept is on the site

Topics are chosen from places engineers visibly get stuck, and the sources are kept with the lesson so the claim is checkable.