Skip to content
Chapter 5 of 8 · MCP authToken passthrough to upstream services

15 min read

Token passthrough to upstream services

On this page

Token passthrough is reading the Authorization header off an incoming MCP request and placing it, unchanged, on an outbound request to something else. It is nine lines, it saves a service registration, and it is the natural thing to write when both systems sit behind the same identity provider. It is also the one practice in this area the MCP specification forbids in as many words: an MCP server MUST NOT accept a token that was not issued for it, and MUST NOT pass a token it received on to an upstream service.

The second half is the interesting one, because it forbids a thing that works. Audience binding, from chapter 3 where the aud claim names a token's intended recipient, is only a boundary while every service checks it, and a passthrough that succeeds is proof that at least one service on the path does not.

What the receiving service just agreed to

ts
// tools/reports.ts
export async function listReports(ctx: ToolContext, args: { since: string }) {
  const res = await fetch(`${REPORTING_BASE}/v1/reports?since=${args.since}`, {
    headers: {
      // the token the MCP client sent us, unchanged
      Authorization: ctx.request.headers.get("authorization")!,
    },
  });
  if (!res.ok) throw new Error(`reporting ${res.status}`);
  return res.json();
}

The token in that header has aud: "https://mcp.northwind-analytics.com/mcp". It is a credential naming your MCP server as its intended recipient, and the reporting service has accepted it as a credential naming the reporting service. Having done so once, it will do so for every token with that audience, which is a population your server controls and reporting has never seen.

Sequence: MCP client / agent host → MCP server (tools/call, Bearer aud=mcp-server); MCP server → MCP server (validate: iss ok, aud ok, scope ok); MCP server → Reporting API (GET /v1/reports, same Bearer aud=mcp-server); Reporting API → Reporting API (signature ok, aud not checked); Reporting API → MCP server (200 rows).

Why the call succeeds is the step that has to be visible, because the entire practice rests on it. Reporting verifies the signature, and the signature is valid: it was produced by the shared identity provider's key, and reporting trusts that key. Reporting then either does not compare aud against its own identifier at all, or compares it against a policy that accepts anything the tenant signed. Nothing else in its validation sequence can tell a token minted for itself from a token minted for your MCP server, because every other claim in the token is about the user, and the user is the same person either way. The acceptance is therefore not a failure of reporting's code so much as the absence of one comparison — and the absence stays invisible until something presents a token that comparison would have caught. A passthrough that succeeds is a measurement rather than a convenience: it has established that at least one service on that path is not checking.

Consider who holds tokens with that audience. The agent host that stores them on the user's laptop. Whatever process cached them for refresh. The crash reporter that captured a request during a 500. The observability pipeline, if anyone logged headers. And the model's context, if a tool ever returned an HTTP trace to the agent for diagnosis. That population exists because MCP tokens are handed to an autonomous, prompt-driven client that talks to untrusted content all day, and it is a much larger and much livelier population than the one holding credentials for an internal reporting API — which, before such a change, is typically a few cron jobs and a service account.

Read as sets, the passthrough performs exactly one operation, and naming it removes the argument about who is trustworthy. Reporting's credential population was a few cron jobs and a service account, every one of them provisioned deliberately and living inside your infrastructure. Your MCP server's population is every place a token addressed to it can end up, which is the list above. After the change, reporting accepts credentials from both, so its effective population is the union of the two — and the union of a small set and a much larger one is the larger one. The small set contributes nothing and constrains nothing. That is the whole security consequence of the nine lines: a boundary between two populations was removed, and a boundary only ever matters in the direction of the larger side.

"The user and scopes are right, so nothing is escalated"

The argument has real content and it is worth answering precisely rather than dismissing. The claim is that the token carries the same sub the user would have had anyway, that reporting will apply that user's own permissions, and that the agent can therefore reach nothing the user could not reach by opening the reporting UI. Three things are wrong with it.

The scopes are not the reporting service's scopes. They were granted by a user consenting to reports:read accounts:read on a consent screen that named your MCP server and described what your MCP server does. Reporting has its own scope vocabulary, and the token either does not use it — in which case reporting is falling back to "authenticated user, full rights" — or coincidentally overlaps, which is worse because it looks correct. What the user agreed to and what reporting is enforcing are two different lists that nobody has reconciled.

Revocation stops working as an administrative control. An operator who removes a user's access to reporting expects the reporting service's grants to be the thing they are removing. The path through your MCP server does not depend on any grant at reporting, so it survives, and the only way to cut it is to revoke the user's MCP grant — which an operator looking at reporting's access list has no reason to know exists.

The audit trail becomes wrong at the point it matters most. Reporting logs a sub and a client_id, and the client_id it logs is the agent host's, because that is what is in the token. Six months from now, an investigation into who exported the pipeline data sees a user and a desktop agent, with no record that the call arrived through a tool invocation, no record of the prompt that caused it, and no way to distinguish a person clicking a button from a model following an instruction embedded in a document it was asked to summarise. The path with the least accountability is the one with the most autonomy behind it.

There is a fourth consequence that is about the estate rather than about this call. Once reporting demonstrably accepts MCP-server tokens, the next team to need reporting data does the same thing, because it is now precedent and it is in the codebase. Permissive audience policies spread by copy-paste, and after a year the tenant contains a set of services that all accept each other's tokens, at which point the audience claim has no operational meaning anywhere and there is no single change that restores it.

Those four answers address four different questions, which is why the original claim can be true in its own terms and still settle nothing. The claim is about permissions: could this user have reached this data another way. The four objections are about permission granted by whom, withdrawal by whom, attribution to whom, and what the next team does with the precedent. Only the first sits on the same axis as the claim, and even there the answer is no, because the scopes were granted against a consent screen describing something else. The other three are untouched by the claim entirely, since a token can carry the correct subject and correct-looking scopes while remaining unrevocable, unattributable and imitable. Answering an argument about permissions with a better argument about permissions is what keeps this discussion circling; the move that ends it is noticing that three of the four consequences never mentioned permissions at all.

The estate-level version composes in a way worth being explicit about. A token is contained only if every service that might receive it checks the audience, so the chance containment holds across the estate is the product of the chances at each service rather than their average — and a product of numbers below one falls as terms are added. Every service joining the tenant multiplies in another factor, so containment degrades with the size of the estate even when no individual service gets worse. Treating those factors as independent then overstates the result, because services configured by one team from one template fail together: a single permissive default copied into a scaffold makes a dozen services one correlated event, so the true figure is closer to the chance of the template being wrong than to the product of a dozen separate chances. Correlation biases this estimate optimistically, which is the direction that gets a design approved.

The end state for both calls

Neither call ends up using the incoming token, and neither ends up using one shared secret.

For an internal service that shares your authorization server, the mechanism that preserves the user's identity across the boundary without laundering the audience is token exchange, RFC 8693. Your server presents the incoming token as the subject, names reporting as the target, and receives a new token whose aud is reporting and whose scopes are reporting's:

bash
curl -sS -X POST https://auth.northwind-analytics.com/oauth2/token \
  -u "$MCP_SERVER_CLIENT_ID:$MCP_SERVER_CLIENT_SECRET" \
  --data-urlencode grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
  --data-urlencode subject_token="$INCOMING_TOKEN" \
  --data-urlencode subject_token_type=urn:ietf:params:oauth:token-type:access_token \
  --data-urlencode resource=https://reporting.internal.northwind.com \
  --data-urlencode scope="reporting:read"
json
{
  "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImIyN2QifQ...",
  "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
  "token_type": "Bearer",
  "expires_in": 300,
  "scope": "reporting:read"
}

The exchanged token names reporting, carries only the scope this tool needs, and lives 300 seconds. Reporting then tightens its own audience check to its own identifier, which is the half of the fix that stops the old pattern from working again. The exchange costs one round trip to the authorization server per call, typically 15 to 40 ms, and is cacheable for the token's lifetime keyed by the incoming token's jti.

A third-party API is a different end state, because the user identity may not travel there at all. For a payments provider reached with one secret key, the answer is a restricted key per purpose rather than one key for everything: a read-only key for the tools that list charges, a separate key with write access limited to the specific resources the refund tool touches, both held in a secrets manager and rotated on a schedule rather than read from a .env that has been copied into four environments. The user identity does not travel to Stripe at all, because Stripe has no notion of your users — which is a real difference between the two upstreams and the reason they get different answers rather than one answer applied twice.

Ranking two findings against each other is a recurring task rather than a one-off, and the rule that came out of that comparison generalises further than the two items in it. Sort by what an attacker must already possess, because that is the term the defender does not control and the one that decides how many attackers are eligible at all. Then let anything already disclosed jump the queue, because disclosure converts a probability into a fact and there is nothing left to weigh. What the rule deliberately does not sort on is severity in isolation, and the reason is that severity multiplies by reachability rather than adding to it: a catastrophic outcome requiring a foothold nobody has is worth less attention than a moderate one available to anybody who can obtain a token, and comparing the outcomes alone drops the factor that separates them.

The controls that look like they cover it

Three defences are commonly offered in review as making the passthrough acceptable, and each is worth having for its own reasons while doing nothing about this.

Mutual TLS between the MCP server and reporting, where each end presents a certificate the other verifies rather than only the server proving its identity, or a service mesh enforcing workload identity, proves that the connection came from your MCP server's workload. That is a useful fact and it constrains who may open the connection; it says nothing about whether the credential travelling inside the connection was issued for the service at the far end. Reporting still accepts a token addressed elsewhere, and a compromise of the MCP server's process — the case that matters, since the MCP server is the thing exposed to agent traffic — carries the workload identity along with it.

Short token lifetimes reduce the window in which a leaked token is useful, which is why 900 seconds is a reasonable default. They do not change what the token opens during that window, and an agent loop makes 900 seconds a long time: a client issuing a tool call every three seconds gets 300 calls out of one token, and an attacker who has one does not need longer.

A private network boundary — reporting is only reachable from the VPC, so an external attacker cannot call it — is a real control against direct access and no control at all against this path, because the request goes through your MCP server, which is inside the VPC by construction and is reachable from the internet by design. The passthrough is precisely a mechanism for laundering an external caller's credential into an internal one.

What those three have in common predicts the fourth one somebody will offer, which is why it is worth naming rather than answering each in turn. Each constrains a different variable from the one at issue. Mutual TLS constrains who may open the connection. Short lifetimes constrain how long a credential lasts. A private network constrains where a request may originate. The passthrough is about none of those. It is about what a credential means at the far end, and a credential's meaning is fixed by the claim inside it and the check performed against that claim, and by nothing else. Any control that changes neither of those two things will pass review, be worth having on its own merits, and leave the boundary precisely where it was. Forwarding a credential is not the same operation as delegating an identity, and only the second of those needs a new claim — so the test to apply to the next proposal is whether it changes the claim or changes the check.

There is a case where forwarding the same token is correct, and it is worth naming so the rule does not get applied where it does not belong. If the receiving service is not a separate resource but part of the same logical service — an internal process serving the same resource identifier, behind the same audience, with the same trust boundary drawn around both — then the token is being handed to its intended recipient and nothing is being crossed. The test is whether the receiving service publishes its own resource identifier and expects tokens naming it. If it does, forwarding is passthrough. If it does not exist as a distinct audience, you have one service in two processes, and the question was never about tokens.

What the rule protects, then, is the only property that made audience binding worth doing: that a token is spendable in exactly one place. A passthrough spends it in two, silently, at the moment of a successful call, and it does so in the direction that matters — from a credential population full of laptops, caches, crash dumps and model context, into a service whose own credential population was three jobs and an account. The replacement is a second credential obtained deliberately, whether that is a token exchange naming the upstream as its audience or a restricted key scoped to one purpose, and the extra round trip is the price of the boundary continuing to exist.

Token exchange works because reporting and your MCP server share an authorization server that will vouch for both. Salesforce does not, and neither does any third-party SaaS: its authorization server has never heard of your issuer and has no reason to accept a token minted by it. That upstream needs a credential obtained an entirely different way, and the next chapter is about the second grant that produces it.

MCP auth — read the current version at https://daviddominguez.dev/learn/en/mcp-auth/token-passthrough-to-upstream-services

Back to top