Chapter 7 of 8 · 15 min read
The confused deputy problem
On this page
An OAuth proxy is an MCP server that sits between its clients and a third-party authorization server: MCP clients authenticate to the server, and the server runs the upstream flows on their behalf, holding one registered OAuth app per SaaS. It is the shape every SaaS integration guide describes, the client ids are configuration rather than per-user state, and onboarding a new user requires nothing to be provisioned anywhere.
The confused deputy is the name for what that shape becomes when combined with an unauthenticated registration endpoint. Norm Hardy described the pattern in 1988: a compiler on a timesharing system held the authority to write to a billing file, a user passed the billing file's name as the output path, and the compiler wrote there — using its own authority to carry out somebody else's request, because it had no way to tell whose authority a request should draw on. A proxy holding a single upstream client id is in the same position, and the consent recorded against that client id is the write permission on the billing file.
That is a precedent rather than a metaphor — a documented case from a different kind of system, offered because the structure repeats — and it is worth saying where the structure stops repeating. The compiler's authority was a file permission held by a program, fixed when it was installed and identical for every user of the machine. A proxy's authority is a consent record held in a third party's browser session, granted per user, and revocable by that user at the SaaS without your proxy ever being told. The deputy here therefore accumulates one authority per user instead of holding one, so its exposure grows with adoption in a way the older case has no equivalent for. What does transfer, precisely, is the defining feature: a program holding authority of its own, taking instructions about what to do with it from a party who does not hold that authority, and having no means of asking on whose behalf it is acting.
Walking the attack
The proxy supports dynamic client registration, because MCP clients arrive without pre-arranged credentials and chapter 1 established that this is how they get a client id. The registration endpoint is unauthenticated, which is what the specification calls for and what makes agent hosts able to connect at all. An attacker registers a client like any other, supplying a redirect_uri on a host they own, and receives a client id from your proxy.
Step five is the whole attack. The SaaS authorization server has a consent record for STATIC_YOURCO in the victim's browser, put there the first time the victim legitimately connected your integration, and consent records exist precisely so that returning users are not asked the same question every session. Seeing an authorization request from a client the user has already approved, for scopes already granted, it does what it is designed to do and redirects immediately. No screen is rendered, so there is nothing for the victim to decline, and the entire sequence completes in the few hundred milliseconds it takes three redirects to resolve.
The half that is easiest to skip is how the attacker obtained a redirect your proxy will honour, and it is not a flaw anybody introduced. An agent host meeting a server it has never seen holds no client id, so the registration endpoint has to be reachable without one — requiring authentication in order to register would mean requiring a credential in order to obtain a credential, which is the loop discovery exists to break. The endpoint therefore accepts a redirect_uri chosen by whoever is calling, records it against a fresh client id, and returns that id. That is the specified behaviour, and it is the only behaviour that lets arbitrary agent hosts connect at all. The attacker uses it exactly as a legitimate client would: nothing in their request is malformed, nothing in the response is a mistake, and the redirect allowlist your proxy consults later is satisfied because it is consulting a list the attacker contributed the relevant line to.
What the attacker ends up holding is a token for your MCP server, bound to the victim's identity. Every tool works. Anything the victim connected — the CRM, the file store, the ticketing system — is reachable through your tool surface at the victim's full rights, and your logs show a normal session for a normal user.
Why the controls from chapter 2 all pass
Each of the three bindings is present, correctly implemented, and irrelevant, and the reason in each case is the same: they detect a discontinuity between the party who started a flow and the party who finished it, and here those are the same party.
| Control | Who holds it here | Why it does not fire |
|---|---|---|
| PKCE on the client-to-proxy leg | The attacker | They generated the verifier and the challenge; the token endpoint verifies them against each other and both are theirs |
| PKCE on the proxy-to-SaaS leg | Your proxy | Correct throughout; the upstream code was never intercepted, it was delivered exactly where it should be |
state on the client-to-proxy leg | The attacker | Their client checks a value it issued, and it matches |
state on the proxy-to-SaaS leg | Your proxy | Also matches; the callback is the one your proxy asked for |
redirect_uri allowlist at your proxy | Your proxy | The attacker's URI is in the allowlist because the attacker registered it, through the endpoint that exists to let clients register |
redirect_uri registration at the SaaS | The SaaS, against your static client id | Satisfied — the upstream code went to your proxy's registered callback, which is the honest one |
The only human in the sequence is the victim, and they are not authenticating a flow they started; they are supplying a browser that already carries an approval. That is the input the three controls do not examine, because none of them has any opinion about whether the consent being relied upon was given for this request.
Six rows, and all six fail for one reason rather than six, which is the useful thing about laying them out together. Every control in that table compares two values and passes when they agree: a verifier against a challenge, a returned state against a stored one, a requested redirect against a registered list. Agreement establishes that the party at the end of a flow is the party who began it. In this attack the party at the end is the party who began it — the attacker started the client-to-proxy leg and finished it, your proxy started the proxy-to-SaaS leg and finished it — so every comparison agrees, and agrees correctly. A control built to detect a substitution cannot detect the absence of a substitution. Nothing here was swapped for anything; a flow that is internally consistent from end to end is riding on an approval that nobody inside it ever asked for, and there is no pair of values whose disagreement would reveal that.
Consent you own, asked at the right moment
The mitigation the specification requires is a consent step of your own, obtained for each dynamically registered client before your proxy will start an upstream flow on its behalf. The screen names the client that is asking and the redirect host where the result will be delivered, and the approval is recorded against that client id rather than inherited from an approval recorded elsewhere.
app.get("/authorize", async (c) => {
const { client_id, redirect_uri, state, code_challenge } = c.req.query();
const client = await clients.get(client_id);
if (!client || !client.redirectUris.includes(redirect_uri)) return c.text("bad client", 400);
const user = await requireProxySession(c); // user logs in to YOU first
if (!await consents.has(user.id, client_id)) {
// Do not touch the upstream authorization server yet.
return renderConsent(c, {
clientName: client.clientName,
redirectHost: new URL(redirect_uri).host,
scopes: c.req.query("scope")?.split(" ") ?? [],
resume: await pending.save({ client_id, redirect_uri, state, code_challenge }),
});
}
return startUpstreamFlow(c, user, client_id);
});Two properties of that handler carry the fix. The upstream flow does not begin until consent for this specific client exists, so the crafted link stops at a screen the victim has to read. And the screen names attacker.example as the destination, which is the piece of information no upstream consent screen can ever show, because the SaaS has no idea your proxy exists as an intermediary or that a second client is behind it.
Forcing prompt=consent on the upstream request is worth doing and does not substitute for this. It makes the SaaS render its screen again, ignoring the cookie, which removes the silent skip. What the victim then reads is "YourCo would like to access your CRM" — a request from a vendor they trust and have already approved — with no mention of the client that initiated it. A prompt that shows the wrong subject is a prompt most people approve.
That near-miss is worth dwelling on, because it is the commonest shape a failed defence takes: the control fires, the human is asked, and the question is about the wrong thing. Consent is a control only when the screen names the party whose access is being decided, and the upstream screen cannot name that party. The SaaS knows your proxy's client id and has no representation at all for a second client standing behind it, because in the arrangement OAuth was designed for there is no second client. Adding a prompt to a screen that cannot show the deciding fact converts a silent approval into an informed-looking one, which is worse than the silent version in one specific respect: it produces a record of the victim having agreed.
The structural version of the fix removes the shared consent record entirely: a distinct client registration at the SaaS per user or per install, so there is nothing for a second flow to inherit. Where the SaaS supports dynamic client registration this is straightforward; most do not, which leaves per-install registration for self-hosted deployments and manual app creation for enterprise customers who are already used to creating a connected app in their own tenant. That is more provisioning work than a design review's ten-minute answer, and it is the arrangement in which a stolen consent is scoped to one installation.
Which deployments are actually exposed
Four conditions have to hold together, and a deployment missing any one of them is not vulnerable to this particular attack. Your server has to proxy authorization rather than merely consume it. The upstream client id has to be shared across users. Something has to let a third party obtain a redirect destination your proxy will honour, which is usually dynamic client registration but can equally be an open redirect on a legitimate registered client. And the victim has to have consented to the upstream client already, which every returning user has.
Because the four are conjoined rather than cumulative, each is a place the attack can be cut, and the two mitigations cut different ones. Consent you own removes none of the four outright; it inserts a requirement the crafted request cannot meet, so the standing approval at the SaaS stops being the last approval in the chain and the fourth condition stops being sufficient on its own. A distinct registration per user or per install removes the second condition, and with it the shared consent record the fourth depends on — which is why it is described as the structural version rather than a stronger version of the same thing. prompt=consent touches none of the four, which is exactly why it changes what the victim sees and not whether the attack completes. That gives a test for any further proposal: name which of the four conditions it removes, and if the answer is none, what has been proposed is hardening rather than a fix.
The arrangement in chapter 6, where the server holds its own per-user grants at the provider, is not exposed, and the reason is worth being precise about, because the two designs look similar on a diagram. There, the upstream grant is not started by an authorization request arriving from a client; it is started by a connect ticket minted against a subject taken from a token your server already validated. An attacker who sends the victim a link to your connect endpoint gets a ticket bound to the victim, and the resulting Salesforce tokens are stored against the victim, where they were going to be anyway. There is no leg of that flow that delivers a credential to a URI the attacker chose, because the attacker never got to choose one. Proxying is what introduces the choice.
That difference is the practical reason to prefer running your own authorization server, or delegating to your organisation's IdP, over proxying a third party's. Proxying makes your server a party in someone else's authorization decision, and every party in an authorization decision has to be able to answer "on whose behalf, and at whose request". A proxy holding one client id can answer the first and not the second.
If you already run the proxy shape, the evidence of exploitation is in two places. The registration log shows client registrations whose redirect_uris point at hosts unrelated to any known agent product, and legitimate registrations cluster on a small set of hosts — loopback addresses, a handful of vendor domains — so unfamiliar ones stand out. The authorization log shows the timing signature: a registration, an authorization request using that client id within minutes, and a token issued without an intervening consent event. Neither is conclusive alone and together they are a reasonable alert, and both are worth having in place before the mitigation ships rather than after, since they also tell you whether it has been used already.
Is "everyone does it this way" an argument?
Partly, and it is worth taking seriously rather than waving off, because prevalence is genuine evidence about something — it tells you the shape is cheap, that vendors have optimised for it, and that a team choosing differently will be swimming against documentation and tooling. Those are real costs and they belong in the decision.
What prevalence does not establish is that the threat model is the same. The industry pattern grew up around server-rendered web applications where the OAuth client is the vendor's own application, users interact with it through a first-party UI, and there is no endpoint through which an arbitrary third party can register a client and obtain a redirect of their choosing. Remote MCP servers add exactly that endpoint, by design, because agent hosts have to be able to connect without prior arrangement. The static client id was safe in a world with no unauthenticated registration in front of it, and the population of deployments that combine the two is a few years old rather than a few decades.
The other half of the answer is that this specific shape is documented as a MUST in the current MCP authorization specification, under the heading of the confused deputy problem. An argument from industry practice is difficult to sustain against the spec for the protocol you are implementing, which describes this attack and requires the mitigation by name.
Three things this is not are worth stating, because each one sends a team down a repair that does not work. It is not a flaw in the SaaS authorization server: remembering consent is intended behaviour, it is what makes OAuth tolerable to use, and a server that re-prompted on every authorization request would be replaced by one that did not. The defect is in the deputy that draws on that memory for requests it did not originate. It is not CSRF — cross-site request forgery — though the shapes rhyme; CSRF forges a request the victim's browser is authorised to make and relies on the target having no way to tell forged from genuine, and the state parameter is the standard answer and is present here. What is being borrowed in this attack is not the victim's session with your proxy, it is an approval the victim gave to a third party months ago. And it is not fixed by shortening code lifetimes, rotating the static client secret, or rate-limiting the registration endpoint: rate limits raise the cost of registering clients in bulk, which is worth doing for other reasons, and one registration is enough for this, while the attack completes inside a normal authorization code's lifetime and uses a client secret only where the honest flow would.
The deputy problem, then, is not about credentials being stolen but about authority being borrowed. Your proxy holds a standing approval from every user who ever connected the integration, that approval is recorded against a client id rather than against a request, and anything that can cause your proxy to start a flow can spend it. Consent you own, asked for each registered client, at the moment the flow starts, is what converts a standing approval into a per-request one — and a distinct registration per user or per install is what removes the standing approval altogether.
Every failure in this topic surfaces as an HTTP status and a challenge header on a specific call, and telling them apart from a trace is the last thing left. A 401 and a 403 look equally like "auth is broken" from inside an agent, they call for opposite responses, and the response most clients reach for first fixes only one of them.
MCP auth — read the current version at https://daviddominguez.dev/learn/en/mcp-auth/the-confused-deputy-problem