Preventing confused-deputy attacks in MCP
A confused deputy is what happens when a server trusts a token it shouldn't have trusted, because the token was valid — just not for this server. In a single-API world it's rare. In an MCP world, where AI agents juggle tokens for dozens of MCP servers, it's the default failure mode unless you wire things correctly.
This page walks through how the attack happens with concrete code, then shows the one-line fix.
The setup
Alice runs Claude Desktop. She authorizes it against two MCP servers:
- Server A —
https://issues.example.com/mcp— read-only access to her bug tracker. - Server B —
https://billing.example.com/mcp— full access to her credit card.
Both servers use the same OAuth authorization server. Claude Desktop gets two access tokens, one per server. So far, fine.
Now suppose Server A is compromised — someone exploits an issue in the issues server's code. The attacker now has access to every token Server A receives.
The attack
The attacker takes a token Alice sent to Server A and replays it against Server B's /mcp endpoint.
If Server B validates the token by only checking:
- Is the signature valid? ✅ (same issuer)
- Is it expired? ✅ (not yet)
- Does it have a
scopeclaim? ✅ (some scope)
...then the attack works. The token is structurally valid; Server B doesn't know it wasn't meant for it. The attacker reads Alice's credit card via Server B using a token Alice gave to Server A.
This is the confused-deputy pattern: Server B is "confused" about whose authority it should be acting on. The token is the user's, but the intent was for Server A only.
The one-line fix: check aud
OAuth tokens have an aud claim — the audience. When the authorization server issued the token, it stamped it with which resource server it was for:
{
"iss": "https://auth.example.com",
"sub": "alice@example.com",
"aud": "https://issues.example.com/mcp",
"scope": "issues:read",
"exp": 1730000000
}
If Server B validates aud matches its own resource URI:
if !slices.Contains(claims.Audience, "https://billing.example.com/mcp") {
return ErrInvalidAudience
}
...then the attack fails. The replayed token has aud = "https://issues.example.com/mcp". Server B's check is for "https://billing.example.com/mcp". Mismatch → token rejected → no breach.
That's it. One check. Everything else about the OAuth dance is bookkeeping in comparison.
What the SDK does
The Go SDK validates aud against Config.ResourceURI on every request. If you set ResourceURI = "https://billing.example.com/mcp", tokens with a different aud get 401 invalid_token with reason audience mismatch.
This is not optional. It's the validation that makes OAuth-on-MCP actually secure.
cfg := authsec.Config{
// ...
ResourceURI: "https://billing.example.com/mcp", // every token's aud must match this
}
Where authorization servers screw this up
The aud check on the resource server only works if the authorization server set aud correctly when it issued the token. Three failure modes:
- No
audat all. Some older OAuth servers issue tokens without an audience claim. The resource server has nothing to check. Solution: setaudienceon the OAuth client's authorization request, or configure your auth server to default it from the resource URI. audis the client_id, not the resource URI. Ory Hydra historically defaulted to this. Tokens for two different clients accessing the same server have differentaudvalues — useless for our check. Solution: use Hydra's audience feature, or checkclient_idinstead.audis a wildcard. Some auth servers issueaud: "*"for convenience. This negates the protection entirely.
AuthSec configures Hydra to set aud per-resource-server. Tokens issued for Server A literally cannot be used at Server B regardless of who the client is.
The threat model
What this protects against:
- A compromised resource server stealing tokens to use elsewhere.
- A malicious MCP server (one a user shouldn't have trusted) trying to escalate via tokens issued for another server.
- Replay attacks where a logged token is reused against a different server.
What it doesn't protect against:
- A compromised authorization server. If the issuer itself is owned, all bets are off.
- A user being tricked into authorizing a hostile client in the first place. That's a consent problem, not an audience problem.
- A token that's legitimately for the target server. Audience binding doesn't prevent valid use.
Beyond audience: DPoP and mTLS
Even with aud binding, a stolen token is still useful to the thief — they can call the legitimate resource server with it until it expires. For higher-stakes deployments, two patterns reduce this window:
- DPoP (RFC 9449) — tokens are bound to a client-side key. Each request includes a signed proof that the caller holds the private key. Stolen tokens are useless without the key.
- mTLS-bound tokens (RFC 8705) — tokens are bound to the client's TLS certificate. Same idea, transport-layer.
AuthSec doesn't issue these today. The standard bearer-token model with aud binding is the floor; DPoP/mTLS is the ceiling. Most MCP deployments are fine at the floor.
Related
- How to add OAuth to an MCP server
- Troubleshoot OAuth tokens — the
audmismatch checklist - Go SDK reference —
ResourceURIconfig