Agentic Security / MCP / authorization / delegated credentials
The Agent Confused Deputy
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.
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.
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.