Skip to main content

Your MCP Server Must Refuse That Token

· 15 min read
TheMCPGuy
MCP Developer & Educator

Series cover. A red row shows a client token passed straight through an MCP server to a third-party API across a trust boundary, marked wrong audience. A green row shows the client getting an audience-bound token from an auth server first. The strapline reads: issued for the MCP server, or refuse it.

You are wrapping a third-party API in an MCP server. The client that connects to you already holds an access token for that API, and it sends the token along. Your server has the token, the API wants the token, and there is a perfectly good Authorization header sitting right there.

Forward it. Ship it. Move on.

The MCP specification says otherwise, in the flattest language it uses anywhere: MCP servers MUST NOT accept any tokens that were not explicitly issued for the MCP server. Most of the security guidance in MCP is a SHOULD. This one is a MUST NOT, and it is worth understanding why the bar is set that high.

In the shortcut, token A is the one the client already holds, issued by the third-party API's own authorization server:

The second arrow is what the specification forbids: your server presents a credential issued to somebody else.

Version disclosure (as of September 2026)

This describes the security best practices and authorization sections of the 2026-07-28 specification, the current revision. The prohibition has been in the spec since the 2025-06-18 revision, which added audience validation and the Security Best Practices document, and it did not change in this one. What did change is the surrounding machinery, noted at the end. Code here is structural, meant to show shape instead of to be pasted into production.

What "token passthrough" actually names

The anti-pattern has two halves, and they fail independently.

Half one: accepting a token that was issued for somebody else. A JWT carries an aud (audience) claim naming the resource it was minted for. If your server reads the token, finds a valid signature from an issuer it recognises, and starts serving requests without checking that aud names your server, then any token from that issuer opens your server. A token a user granted to an unrelated application now works against yours.

Half two: forwarding that token onward. Your server takes the credential it was handed and replays it, unchanged, against the downstream API. The API sees a request that looks like it came from the original client.

Half one is the audience validation failure. Half two is the passthrough. A server can commit the first without the second, and it is still broken.

Four reasons the spec makes this a MUST NOT

The specification lists the risks under four headings, and they are worth reading as four separate arguments instead of one:

The spec's headingWhat stops working
Security Control Circumventioncontrols that depend on who the token was issued to
Accountability and Audit Trail Issueslogs on both sides that name the wrong caller
Trust Boundary Issuesthe downstream API's assumption about who calls it
Future Compatibility Riskthe controls you will want to add later
  • Security controls stop applying. Rate limits, request validation and traffic monitoring are usually keyed to the audience of a credential or to some other constraint on it. A token that reaches the downstream API without your server checking whether it was meant for you has walked around whichever of those controls depended on knowing who was calling.
  • The audit trail stops being true. Your server cannot distinguish one MCP client from another when they all arrive holding upstream-issued tokens that may be opaque to you. Downstream, the API's logs show requests that appear to come from a different identity than the one actually forwarding them. When you come to investigate an incident, the logs point at the wrong party, which is more expensive to unpick than a gap in the logs would have been.
  • Trust boundaries move without anyone deciding to move them. The downstream API granted trust to a specific party, with assumptions about who calls it and how. If a token is accepted by several services without proper validation, an attacker who compromises one of them can use the same token against every other service that accepts it.
  • You are choosing today which security model you can have later. A server that starts as a pure proxy usually acquires controls of its own eventually. Starting with proper audience separation is what makes that possible without a migration.

How it becomes the confused deputy

The passthrough half is the ingredient in a larger attack the spec documents separately.

A confused deputy is a component with more authority than its caller, tricked into using that authority on the caller's behalf. An MCP proxy server that holds a static client id with a third-party authorization server is exactly such a deputy.

The attack starts from a legitimate authorisation: the user approves the proxy once, and the third-party authorization server sets a consent cookie for that static client id. The attacker's steps follow:

The skipped consent screen is the step that matters: the third-party authorization server treats the attacker's link as the approval the user already gave.

A server that forwards tokens without validating them is already living in that world. It has accepted that a credential's audience is somebody else's problem, and audience is the mechanism that would otherwise have refused.

The mitigations the spec requires of a proxy server are worth knowing even if you do not build one:

  • Per-client consent, stored server side and checked before the third-party flow begins.
  • Exact string matching on redirect URIs, with patterns and wildcards excluded.
  • Single-use state values, stored only after consent has been approved. state is the OAuth parameter that ties a callback back to the request that started it.
  • Consent cookies with the __Host- prefix, plus Secure, HttpOnly and SameSite=Lax. The prefix binds the cookie to one origin.

What to do instead

The shape that works has three steps, and none of them is difficult once you stop thinking of the token as cargo. Two tokens travel this path:

Token B is the only credential the third-party API sees, and it names your server as the party that asked.

  1. Be a resource server, and act like one. Your MCP server is an OAuth 2.1 resource server: the party that holds the protected data and accepts access tokens for it. It publishes protected resource metadata, it challenges unauthenticated requests with the WWW-Authenticate header, and it validates every token it accepts: signature, issuer, expiry, and audience. The audience check is the one people skip, and it is the one this article is about.

  2. Get your own credential for anything downstream. If your server calls a third-party API, it needs a credential issued to it, and which one depends on the downstream provider. An OAuth token exchange (RFC 8693) takes the caller's token and swaps it for one whose audience is the downstream API. A client credentials grant gets your server a token on its own account. A per-user credential is one your server stores after its own authorization flow with that provider. In every case the credential your server presents downstream is one issued to your server, and the inbound token stays where it arrived.

  3. Ask for the narrowest scopes that will do. The spec's scope minimisation guidance is a direct consequence of the same reasoning: a stolen broad token is a worse day than a stolen narrow one. Start from a minimal set, and elevate through targeted WWW-Authenticate challenges when a privileged operation is first attempted.

The audience check, in shape

In a Spring Boot resource server, the audience check is a validator you add alongside the default ones. The important part is what it asserts, not the API surface:

JwtDecoder decoder(String issuer, String thisServer) {
NimbusJwtDecoder decoder = JwtDecoders.fromIssuerLocation(issuer);
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer(issuer),
new JwtClaimValidator<List<String>>("aud",
aud -> aud != null && aud.contains(thisServer))));
return decoder;
}

JwtValidators.createDefaultWithIssuer gives you expiry, not-before and issuer. It does not give you audience, and that is the gap this closes. A token minted for another service, signed by an issuer you trust, is rejected here instead of being served.

Three things worth stating plainly about the code above. It is structural: check it against the Spring Security version you are on before you rely on it. The value you compare against is your server's own resource identifier, the same one you publish in your protected resource metadata, so that a client asking its authorization server for a token to call you names you specifically.

Here is what the defaults cover, and what they leave to you:

Claim or headercreateDefaultWithIssuerWhat you still add
exp and nbfcheckedalready covered
isscheckedalready covered
typmust be JWT or absentacceptance of at+jwt, when your authorization server issues RFC 9068 tokens
audleft to youthe audience check in the code above

The typ row is the second gap in the defaults, and it catches people out. RFC 9068 access tokens, the profile the MCP spec points at when it talks about audience claims, use typ: at+jwt. If your authorization server issues those, the default chain rejects them whatever your audience check decides, and the symptom is a 401 that looks like a signature problem. Add JwtTypeValidator for at+jwt when that applies to you, or start from JwtValidators.createAtJwtValidator(), which Spring Security 6.5 added to build the whole RFC 9068 chain.

What changed in 2026-07-28 around this

The prohibition is old. The machinery around it moved in the current revision, and two changes touch this directly.

  • Dynamic Client Registration is deprecated in favour of Client ID Metadata Documents, where a client identifies itself with an HTTPS URL that serves its own metadata. Dynamic registration is one of the four conditions the confused deputy attack needs, so this is a change with a security reason behind it. It keeps working for authorization servers that cannot do CIMD yet.
  • Clients must validate iss on authorization responses when one is present (RFC 9207), against the issuer they recorded before redirecting. This mitigates mix-up attacks, where an attacker-controlled authorization server persuades a client to send it a code issued by an honest one. PKCE, which makes a client prove that the code it redeems is the one it asked for, does not stop that on its own, because the client hands its code_verifier to whichever token endpoint it was steered towards.

Three questions for your server

  1. Does your token validation assert the aud claim against your own identifier? If the answer is "the signature and the issuer check out", that is a no.
  2. Does any code path put an inbound Authorization header on an outbound request? Search for it. That is the passthrough, and it usually looks like tidy code reuse.
  3. If someone hands your server a valid token that was minted for a different service, what happens? The correct answer is a 401 with a WWW-Authenticate challenge.

The difficulty with this one is that getting it wrong feels fine. Every request succeeds, every integration test passes, and the problem stays invisible until somebody points a token at your server that you should have refused.


If you are building remote servers, MCP Just Went Stateless covers the other half of this, including State Handle Hijacking, which is the same lesson applied to the handles that replaced sessions. The Security Model module of MCP Fundamentals works through the trust boundaries from first principles.

Further Reading

Sources