Key takeaways
- The MCP specification states that if a server calls upstream APIs it acts as an OAuth client to them, that the upstream token is a separate token, and that the server MUST NOT pass through the token it received from the client. This is normative, not advisory.
- The reason is the confused deputy problem. A token minted for your MCP server carries your server's audience; forwarding it to a third party either fails audience validation or, worse, succeeds against a service that does not check.
- The specification does not define how the server obtains the upstream token on the user's behalf. Three strategies exist and they differ in which identity survives: client credentials loses the user, a separate per-user OAuth flow keeps it at a consent cost, and RFC 8693 token exchange preserves it but is not standardised in MCP.
- Store upstream grants against a compound key of issuer and subject from the validated inbound token, never against the token string itself. Keying on the token orphans every stored grant the moment the client refreshes.
- A missing upstream grant is not an error. It is a consent prompt, and modelling it as a distinct signal rather than a failed tool call is what makes the flow recoverable.
- Level
- advanced
- Time to implement
- One to two days to add a broker to an existing MCP server
- Written for
- Engineers
Your MCP server has a validated bearer token from the client. A tool now needs to open a pull request on the user's behalf. You have their identity, you have a token, and GitHub is one HTTP call away. Forwarding the token you already hold is the obvious move, it takes one line, and it is explicitly forbidden.
This is the point most MCP servers stop being demos. It is widely described as the specification only covering the first hop, which is not quite right and matters: the authorization security considerations address the second hop directly. They tell you what you must not do. They do not tell you what to do instead.
“If the MCP server makes requests to upstream APIs, it may act as an OAuth client to them. The access token used at the upstream API is a separate token, issued by the upstream authorization server. The MCP server MUST NOT pass through the token it received from the MCP client.”
— MCP Authorization — Security Considerations
Why passthrough is forbidden rather than merely discouraged
The prohibition is not stylistic. It follows from what the token actually is. Under the current specification an MCP client must include an RFC 8707 resource parameter naming the MCP server, and the server must validate that tokens presented to it were issued specifically for it as the intended audience. The token is bound to your server by design.
Forward that token upstream and exactly one of two things happens, and both are bad. A correctly implemented upstream validates the audience claim, sees your MCP server rather than itself, and rejects the request — so you have built a system that fails in production for reasons that look like a bug in someone else's service. Or the upstream does not check the audience, accepts it, and you have just proved that a token minted for one service works at another.
The second outcome is the confused deputy problem, and it is worse than a broken integration. Your MCP server holds credentials and acts on behalf of users; if it will relay a caller's token to arbitrary upstreams, anyone who can reach it can borrow its position in the network. Audience binding is the control that stops that, and passthrough is precisely the act of discarding it.
The three ways to get an upstream token
Since you cannot forward the token, the server has to obtain its own. There are exactly three available strategies, and the honest way to choose between them is to ask which identity survives the hop.
| Strategy | Identity at the upstream | Consent cost | Use when |
|---|---|---|---|
| Client credentials | The server's own service account. The user is invisible. | None | The upstream has no per-user model, or you genuinely want service-level access with your own audit trail |
| Per-user OAuth to the upstream | The actual end user, with their own grant and scopes | One consent screen per user per upstream | The upstream enforces per-user permissions and you need them to hold — the default for anything touching user data |
| Token exchange (RFC 8693) | The end user, via a delegation token minted by your IdP | None after initial setup | Your identity provider supports it and the upstream trusts the same issuer — clean, but not standardised in MCP |
Client credentials is the one most teams reach for because it is easiest, and it is the one that most often fails an audit. Every action arrives at the upstream as the service account, so the upstream's own permission model no longer applies and its logs cannot tell you which human did what. The MCP extensions repository does list a Client Credentials extension, currently in draft, but it addresses service-to-service authentication rather than acting on a user's behalf. It is the right tool for a narrow job and the wrong answer to this one.
Token exchange is the architecturally clean answer: the server presents the inbound token to an authorization server that trusts it and receives a token for the upstream, preserving the subject. RFC 8693 exists precisely for this. The practical obstacle is that MCP does not standardise it — the official extensions repository currently carries two extensions, Enterprise-Managed Authorization and Client Credentials, and no token exchange. If your identity provider supports it you can build it, but you are building something bespoke rather than something interoperable.
That leaves per-user OAuth as the default for anything touching user data. It costs a consent screen. The rest of this article is about making that cost survivable.
The broker: one place that owns upstream credentials
The mistake is to scatter upstream token handling through the tool implementations, so each one grows its own refresh logic and its own idea of where credentials live. Consolidate it into a broker with a single responsibility: given a validated caller identity and the name of an upstream, return a usable access token or say clearly that consent is needed.
hop 1 hop 2
-------------------------------- --------------------------------
MCP client --[token A]--> MCP server --[token B]--> Upstream API
|
validate A
(audience = this server)
|
subject = iss|sub
|
broker.token_for(subject, "github")
|
token B, minted for the upstream,
never derived from token AThe two tokens never touch. Token A is validated and then used only to establish who is calling; token B is fetched against that identity. Written this way the prohibition stops being a constraint you work around and becomes a property the design has for free.
Key on the subject, never on the token
This is the detail that bites people three weeks in. It is tempting to cache upstream grants against the inbound token, because it is right there and it is unique. It is also short-lived: the moment the client refreshes, every stored grant for that user is orphaned and the user is asked to re-consent to every upstream they had already approved.
Key on the sub claim from the validated token instead, compounded with the iss claim. Subjects are only unique within an issuer, so a bare sub collides the day you add a second identity provider — and a collision here means handing one user's upstream credentials to another.
def subject_key(claims: dict) -> str:
"""Stable per-user key from an already-validated inbound token.
Compound because `sub` is only unique within an issuer. A bare `sub`
collides the day a second IdP is added, and a collision here leaks one
user's upstream credentials to another.
"""
issuer = claims.get("iss")
subject = claims.get("sub")
if not issuer or not subject:
raise ValueError("inbound token carries no usable subject identity")
return f"{issuer}|{subject}"Implementation
The grant record carries an expiry and refuses to answer close to it. Five minutes of skew is deliberate: a token that expires while the upstream request is in flight produces a 401 that looks like a permissions bug and costs a retry you did not need.
import time
from dataclasses import dataclass
# Refresh this far before nominal expiry. A token that dies mid-request
# surfaces as a 401 that reads like a permissions fault.
EXPIRY_SKEW_SECONDS = 300
@dataclass(frozen=True)
class Grant:
access_token: str
expires_at: float
refresh_token: str | None = None
@property
def usable(self) -> bool:
return time.time() < self.expires_at - EXPIRY_SKEW_SECONDS
class UpstreamAuthRequired(Exception):
"""Not a failure — a request for consent.
Raised when no grant exists for this (user, upstream) pair, or when the
stored grant expired with no refresh token. Carries the URL the client
should send the user to.
"""
def __init__(self, upstream: str, authorize_url: str):
super().__init__(f"user consent required for {upstream}")
self.upstream = upstream
self.authorize_url = authorize_urlThe broker itself is small, which is the point. Everything hard has been pushed into the store and the refresher, and both are boring.
class TokenBroker:
"""Returns an upstream token for a caller, or asks for consent."""
def __init__(self, store, refresher, authorize_url):
self._store = store # get(key, upstream) / put(key, upstream, grant)
self._refresh = refresher # (upstream, refresh_token) -> Grant
self._authorize_url = authorize_url
def token_for(self, subject: str, upstream: str) -> str:
grant = self._store.get(subject, upstream)
if grant is None:
raise UpstreamAuthRequired(
upstream, self._authorize_url(subject, upstream)
)
if grant.usable:
return grant.access_token
if grant.refresh_token is None:
# Expired and unrefreshable. Drop it so the next call does not
# retry a grant that cannot work.
self._store.delete(subject, upstream)
raise UpstreamAuthRequired(
upstream, self._authorize_url(subject, upstream)
)
refreshed = self._refresh(upstream, grant.refresh_token)
self._store.put(subject, upstream, refreshed)
return refreshed.access_tokenAt the tool boundary, the consent signal is translated into something the client can act on rather than a stack trace. An MCP tool that returns a clear instruction is recoverable; one that raises an opaque 500 sends the agent into the retry loop that turns a permissions problem into a billing problem.
def open_pull_request(ctx, repo: str, title: str, body: str) -> dict:
subject = subject_key(ctx.validated_claims)
try:
token = broker.token_for(subject, "github")
except UpstreamAuthRequired as needed:
# A tool result, not an exception. The agent can surface this to the
# user and retry once consent is granted.
return {
"status": "authorization_required",
"upstream": needed.upstream,
"authorize_url": needed.authorize_url,
"message": "Connect your GitHub account to continue.",
}
return github.create_pull_request(
token=token, repo=repo, title=title, body=body
)That last detail connects directly to cost. An agent that receives an unstructured error will typically replan and try again, and because replanning re-ingests the failure the context grows on every attempt — the mechanism behind the runaway bills covered in why AI agent costs explode in production. A structured authorization_required result terminates that loop before it starts.
Common mistakes
- Forwarding the inbound token because the upstream accepted it in testing. An upstream that accepts a token minted for a different audience is failing to validate, and you are depending on that bug.
- Decoding the inbound JWT without verifying its signature and audience, then trusting the subject. This lets the caller nominate whose upstream credentials to spend.
- Caching upstream grants against the inbound token string. Every client refresh orphans them and re-prompts the user for consent they already gave.
- Using a bare sub as the storage key. Subjects are unique per issuer, not globally, so this collides the day a second identity provider is added.
- Reaching for client credentials because it is quickest, on an upstream that has a per-user permission model. Every action lands as the service account and the upstream's own authorization stops applying.
- Raising an exception when consent is missing. Missing consent is a normal state on first use, and modelling it as a fault produces retry storms rather than a prompt.
- Storing refresh tokens in the same place as the session. They outlive sessions by design, which is exactly why they need stricter storage than the inbound token does.
Security and performance considerations
The broker concentrates every upstream credential your server holds into one component, which is both the benefit and the risk. The benefit is that rotation, revocation, and audit have exactly one place to happen. The risk is that a read primitive against that store is a full compromise of every user's connected accounts, so it deserves encryption at rest with a key the application does not also use for less sensitive data, and it should never appear in logs — including inside exception messages, which is the usual leak.
There is a scope question worth settling early. It is tempting to request broad upstream scopes once so no tool ever has to prompt again. That converts a per-tool permission into a standing grant over everything the upstream exposes, and it is the difference between a bounded incident and an unbounded one. Request the narrowest scope a tool needs and let the step-up flow the specification already defines handle the rest.
On performance: the store is read on every tool call that touches an upstream, so it wants to be fast and it wants a single-flight guard. Without one, an agent firing five parallel tool calls against an expired grant triggers five simultaneous refreshes, and most authorization servers respond to that by invalidating the refresh token — turning a routine renewal into a forced re-consent.
Troubleshooting
- Upstream returns 401 with a valid-looking token — check the audience claim first. If it names your MCP server rather than the upstream, something is still forwarding the inbound token.
- Users are re-consenting constantly — you are almost certainly keying grants on the inbound token rather than on the subject. Check what the store key actually contains.
- Consent works for one user and silently uses another's data — a bare sub key colliding across issuers. Move to the compound key and treat existing rows as untrusted.
- Refresh works in isolation and fails under load — concurrent refreshes racing. Add single-flight per (subject, upstream) before assuming the provider is at fault.
- Everything works locally and fails deployed — STDIO locally picks credentials from the environment while the deployed HTTP transport runs the full flow. These are different code paths and the local one proves nothing about the deployed one.
MCP is moving quickly and the authorization surface has changed more than most of the material written about it reflects — Dynamic Client Registration is now deprecated in favour of Client ID Metadata Documents, and resource indicators are mandatory. Read the authorization specification directly rather than a summary of it, and check the date on anything else you rely on.
If you are putting an MCP server into production and the auth boundary is where it has stalled, that is the sort of work our agent engineering practice handles. If you already have something running and need it reviewed before it touches customer data, an AI consulting engagement starts with the token flow and works outward.
Frequently asked questions
Can an MCP server ever forward the client's token to an upstream API?
What does MCP define for obtaining the upstream token instead?
Is client credentials an acceptable answer for upstream access?
Why key stored upstream grants on issuer and subject rather than the token?
How should an MCP tool behave when the user has not connected the upstream yet?
References
- [1]Authorization — Model Context Protocol specification — Model Context Protocol, accessed 7 August 2026
- [2]Authorization Security Considerations — Model Context Protocol, accessed 7 August 2026
- [3]MCP Authorization Extensions — Model Context Protocol, accessed 7 August 2026
- [4]RFC 8707 — Resource Indicators for OAuth 2.0 — IETF
- [5]RFC 8693 — OAuth 2.0 Token Exchange — IETF
Revision history
First published. Spec statements verified against the MCP authorization specification and its security considerations on the same date.