An MCP server must reject any access token that was not issued for it, and must never forward the caller's token to an upstream API. Both patterns are called out as prohibited in the MCP authorization specification, and both show up constantly in servers that began as an internal HTTP proxy and grew an OAuth flow later. The fix is three concrete pieces of work: validate the aud claim on every request, make clients bind tokens to your server with RFC 8707 resource indicators, and hold upstream credentials in a per-user store your server owns. This article covers how each one fails, what the code looks like, and how to test a server you already run.
Key takeaways
- The MCP spec states that servers MUST NOT accept tokens that were not explicitly issued for them, and MUST NOT pass received tokens through to upstream services.
- Audience validation is the whole defence. If you verify only the signature and the issuer, any token from your identity provider opens your tools, including tokens minted for unrelated APIs.
- Clients must send the
resourceparameter (RFC 8707) so the authorization server can scope the token to your canonical MCP URI. Servers advertise that URI through protected resource metadata (RFC 9728) and aWWW-Authenticateheader on 401. - A proxy MCP server with one static upstream client ID creates the confused deputy problem: a consent cookie from an earlier user lets an attacker's redirect harvest an authorization code silently.
- Session identifiers are transport state, not identity. Every request needs its own token check.
- For anything touching customer data, per-user credentials in a server-side vault beat a shared service account, because a prompt-injected agent then inherits one user's permissions instead of the whole tenant's.
What token passthrough looks like in code
Token passthrough is any code path where the value of the incoming Authorization header reaches an outbound request. It usually appears when a team wraps an existing internal API in MCP and keeps the header plumbing that already worked for their own frontend.
// WRONG: the caller's token is forwarded to a different API
app.post('/mcp', async (req, res) => {
const token = req.headers.authorization; // never validated
const upstream = await fetch('https://api.crm.example.com/v3/contacts', {
headers: { authorization: token }, // token passthrough
});
res.json(await upstream.json());
});
There are two separate defects here. The server never checks who the token was issued for, and it delegates authorization entirely to the upstream API. The MCP security best practices page treats these as distinct anti-patterns, and the authorization specification makes the audience requirement normative: an MCP server acting as an OAuth 2.0 resource server must validate that tokens were issued specifically for it.
Why forwarding the caller's token is a security bug
Forwarding breaks three properties you need at once: authorization scope, audit attribution, and blast radius. Each fails in a way that is hard to see from logs.
Scope. The token was issued for the CRM, with CRM scopes. Your MCP server exposes a curated set of eight tools, but the forwarded token can reach every endpoint the CRM offers. Your tool surface stops being the boundary, so scope decisions you made in the tool schema are decorative.
Attribution. The upstream API records the call as coming from the original client application, not from your MCP server on behalf of an agent. When someone asks which agent deleted 400 contacts on Tuesday, the upstream audit log cannot tell you, and neither can yours if you only logged the JSON-RPC method name.
Blast radius. A stolen or leaked token now works against both your server and the upstream API, and revoking it at one place does not revoke it at the other. This is the same reasoning behind giving agents narrow, separately revocable identities, which I covered in scoped identities for agents.
How to validate that a token was issued for your server
Verify the signature against the identity provider's JWKS, then assert the issuer and the audience against your server's canonical URI on every request. Signature plus issuer alone is the most common half-measure, and it accepts any token your identity provider ever minted.
import { createRemoteJWKSet, jwtVerify } from 'jose';
const JWKS = createRemoteJWKSet(new URL(process.env.IDP_JWKS_URL));
const CANONICAL_URI = 'https://mcp.example.com/mcp';
export async function requireToken(req) {
const header = req.headers.authorization || '';
if (!header.startsWith('Bearer ')) throw unauthorized();
const { payload } = await jwtVerify(header.slice(7), JWKS, {
issuer: process.env.IDP_ISSUER,
audience: CANONICAL_URI, // the line that stops cross-audience reuse
});
return {
subject: payload.sub,
scopes: String(payload.scope || '').split(' '),
};
}
function unauthorized() {
const err = new Error('invalid_token');
err.status = 401;
err.headers = {
'WWW-Authenticate':
'Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"',
};
return err;
}
For the audience to be present in the first place, the client has to ask for it. The MCP authorization spec requires clients to send the resource parameter defined in RFC 8707 on both the authorization request and the token request, set to the canonical URI of the target server. Your side of the contract is RFC 9728 protected resource metadata plus the WWW-Authenticate header above, so a client that gets a 401 can discover which authorization server to use:
GET /.well-known/oauth-protected-resource
{
"resource": "https://mcp.example.com/mcp",
"authorization_servers": ["https://idp.example.com"],
"scopes_supported": ["crm:read", "crm:write"],
"bearer_methods_supported": ["header"]
}
If opaque tokens are all your provider issues, use RFC 7662 introspection instead of local JWT verification and check the returned aud and active fields with the same strictness. Cache introspection results for a few seconds at most, keyed by a hash of the token, and never across users. The end-to-end flow setup, including dynamic client registration, is in adding OAuth 2.1 authorization to a remote MCP server.
How the confused deputy problem hits proxy MCP servers
The confused deputy appears when your MCP server sits in front of a third-party identity provider using a single static client ID for all users. Because every user's authorization request arrives at the upstream provider with the same client ID and the same registered redirect URI, the upstream consent screen may be skipped for anyone who already consented once.
The attack runs like this. A user authorizes your server normally, so the upstream provider sets a consent cookie in their browser. An attacker sends that user a crafted authorization URL pointing at your server, with a dynamically registered client and an attacker-controlled redirect URI. The upstream sees a familiar static client ID plus a valid consent cookie, skips the prompt, and redirects. Your server, doing its job as a proxy, forwards the authorization code to the attacker's URI. The attacker exchanges it for a token that acts as the victim.
Two mitigations, and you want both. Obtain explicit consent on your own server for each dynamically registered client before forwarding to the upstream, and keep an allowlist of redirect URIs per client rather than trusting whatever the registration request supplied. This is a variant of the delegation threats described in the OAuth 2.0 threat model, and it is the reason the MCP security guidance singles out proxy topologies.
Where upstream API credentials should actually live
Upstream credentials belong in a server-side store keyed by the validated sub from the MCP token, never in the request path. The MCP token proves who is calling; a separate credential authorizes the outbound call. Keeping the two separate is what makes rotation and revocation independent.
// identity from the validated MCP token,
// upstream credential from your own vault
async function listContacts(args, ctx) {
requireScope(ctx, 'crm:read');
const cred = await vault.get(ctx.subject, 'crm');
if (!cred) return needsAuthorization('crm'); // structured, recoverable error
const accessToken = cred.expiresAt < now()
? await refreshAndStore(ctx.subject, cred)
: cred.accessToken;
const res = await fetch(CRM_BASE + '/v3/contacts?limit=' + args.limit, {
headers: { authorization: 'Bearer ' + accessToken },
});
return toToolResult(res);
}
Note the needsAuthorization branch. A missing upstream credential is a normal state for a new user, so return it as a tool result the agent can act on rather than a transport error, along the lines of MCP tool errors agents can recover from. If your identity provider supports RFC 8693 token exchange, you can trade the incoming MCP token for a downstream token scoped to the upstream API, which preserves the user identity without ever reusing the original token. That is the cleanest option when it is available, and it is still not passthrough, because the downstream token has a different audience.
| Pattern | Per-user audit | Blast radius if agent is compromised | When I use it |
|---|---|---|---|
| Forward caller token | No | Everything that token can reach | Never |
| Shared service account | No, unless you log sub yourself | Whole tenant | Internal read-only tools on non-sensitive data |
| Per-user vault | Yes | One user's permissions | Default for customer data |
| Token exchange (RFC 8693) | Yes, upstream too | One user, narrowed scopes | When the identity provider supports it end to end |
Why a session id is not authentication
An Mcp-Session-Id identifies transport state and must not be used to authorize requests. The spec is explicit that servers must not use sessions for authentication, and the reason is that session identifiers travel in headers, land in proxy logs, and are frequently generated with predictable schemes.
The failure mode is session hijacking. If a stolen or guessed session id lets someone resume an authenticated stream, or inject events that a client will later read from a shared event queue, then your entire authorization story reduces to the entropy of that string. Bind the session to the authenticated user (a key such as hash(sub + sessionId)), verify the token on every request including resumptions, and generate ids with a cryptographically secure random source. If you are also trying to keep the server horizontally scalable, where MCP server state lives without sessions covers the storage side.
While you are in the transport layer, validate the Origin header on HTTP requests and bind local servers to 127.0.0.1 rather than 0.0.0.0. The transports section of the spec requires this to prevent DNS rebinding attacks against locally running servers, which is a genuine risk once a browser can reach a port that trusts localhost callers.
How to test a server you already run
Three curl requests tell you whether audience validation, discovery, and session handling are correct. Run them against a deployed environment, not a unit test, because misconfiguration usually lives in the gateway rather than the handler.
# 1. Does the server reject a token minted for a different audience?
curl -s -o /dev/null -w '%{http_code}\n' https://mcp.example.com/mcp \
-H "authorization: Bearer $TOKEN_FOR_ANOTHER_API" \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
# expect 401. A 200 here means you are not checking aud.
# 2. Is protected resource metadata discoverable?
curl -s https://mcp.example.com/.well-known/oauth-protected-resource | jq .
# 3. Does a session id alone authenticate?
curl -s -o /dev/null -w '%{http_code}\n' https://mcp.example.com/mcp \
-H "mcp-session-id: $SID" -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
# expect 401
Then grep the codebase for the passthrough shape. Any line where req.headers.authorization or its equivalent flows into an outbound client is a finding, and so is any HTTP client constructed inside a tool handler that reads the incoming request object. A useful CI check: fail the build if the string headers.authorization appears anywhere outside your token verification module.
For manual exercise of the flow, MCP Inspector will drive the OAuth handshake and show you the token it obtained, which makes it easy to decode the aud claim and confirm the resource parameter took effect. Setup notes are in debugging an MCP server with MCP Inspector.
Trade-offs and what I would ship
The honest tension is between per-user OAuth and a shared service account. Per-user credentials mean a vault, refresh handling, an authorization prompt for every new user, and a support path for expired grants. A service account means one secret in your environment and a working demo this afternoon. Teams pick the service account and tell themselves they will migrate, and mostly do not.
My recommendation splits on data sensitivity rather than on effort. If the tools only read data that every authenticated user of the tenant may already read (build status, public catalogue, internal docs that are not access-controlled per person), a service account with authorization checks in your own handlers is defensible, provided you log the validated sub on every call so attribution survives. The moment a tool can read personal data or write anything, go per-user. A prompt-injected agent with a tenant-wide service account is an incident that spans every customer; the same injection with a per-user token is one user's mailbox, which is the difference between a postmortem and a breach notification. That threat model is worth reading alongside defending tool-using agents against prompt injection.
Two smaller calls I would make the same way every time. First, do not build your own authorization server. Sit behind an existing identity provider as a resource server, which is exactly the role the MCP spec assigns you, and which OAuth 2.1 already specifies in detail. Second, enforce the audience check in application code even if a gateway in front of you claims to do it. Gateways get reconfigured, rules get copied between environments with the wrong audience string, and the check costs microseconds once the JWKS is cached.
One caveat on versions. The requirements cited here landed in the 2025-06-18 revision of the specification and carry forward, but revision dates are pinned in the protocol handshake, so confirm the behaviour against the revision your clients negotiate before assuming a client sends the resource parameter at all. Older clients will not, and your server has to decide whether to reject them or accept a grace period with logging. I log and reject, because a token without a verifiable audience is exactly the thing this whole design is meant to refuse.
Sources
- MCP specification, Authorization (2025-06-18)
- MCP specification, Security Best Practices
- MCP specification, Transports
- RFC 8707, Resource Indicators for OAuth 2.0
- RFC 9728, OAuth 2.0 Protected Resource Metadata
- RFC 8693, OAuth 2.0 Token Exchange
- RFC 6819, OAuth 2.0 Threat Model and Security Considerations
- OAuth 2.1 draft specification
- Anthropic docs, connecting Claude Code to MCP servers