October 20, 2026
One Request, Two Users: Checking Delegate Tokens at the MCP Gateway
A travel desk books a family holiday. One agent coordinates it, and it hands the work to three helpers: one books flights, one books theβ¦

By Gowthaman Arumugam
4 min read
A travel desk books a family holiday. One agent coordinates it, and it hands the work to three helpers: one books flights, one books the hotel, one takes payment.
Each helper gets a signed slip that says "I am acting for Ana". The flights helper checks its slip. Valid. The hotel helper checks its slip. Also valid. But the hotel slip says Ravi, not Ana.
Nobody did anything wrong at their own desk. The booking still ran half as one customer and half as another. The only place that could see the mismatch was the coordinator's desk, where both slips passed through together.
That is the problem the latest release of my MCP gateway, mcp-bastion 1.2.0, was built to check.
What happened
On 16 October I released mcp-bastion 1.2.0 and mcp-defense-bench v0.9.0 together.
The gateway side. Bastion can now sit between an agent and several sub-agent MCP servers and forward each signed-in user's own identity headers to the right upstream. It holds no credential of its own in this mode. Each upstream can be told which delegate token to expect, and Bastion checks that token on every request before forwarding it.
The benchmark side. The benchmark gained a new family of five vectors for agent-to-sub-agent delegation:
- Audience confusion: a token reaches a party it was not issued for. That covers a sibling's token routed sideways, and a token meant for the gateway itself passed straight on.
- Subject mixing: one request carries tokens for different users.
- Stale relay: a token that is expired, not yet valid, has no expiry, or lives far longer than policy allows.
- Chain laundering: an agent nobody authorised is named as the actor, or the chain of prior actors is deep or loops.
- Signature forgery:
alg: none, an attacker's key, algorithm confusion, a key URL in the header, or a payload changed after signing.
Why it matters
Multi-agent systems are being built faster than their identity plumbing. The common shape is a coordinator that gets one short-lived token per sub-agent and passes each one on. Each sub-agent validates its own token, which is correct and necessary.
But some failures are invisible from where each sub-agent sits. Subject mixing is the clearest case: every token is valid on its own. Only a component that sees the whole request can say "these name two different people". In practice, that is the gateway.
The MCP spec is direct about the related case. Its security best practices say: "MCP servers MUST NOT accept any tokens that were not explicitly issued for the MCP server." Audience confusion is what happens when that rule is checked in some places and not others.
The mechanism
The token claims in the subject-mixing fixture, decoded, look like this (signatures and IDs trimmed):
{
"x-delegate-flights": { "iss": "https://idp.travel.test", "sub": "user:ana.k",
"aud": "https://flights.travel.test/mcp", "act": { "sub": "trip-orchestrator" } },
"x-delegate-hotels": { "iss": "https://idp.travel.test", "sub": "user:ravi.m",
"aud": "https://hotels.travel.test/mcp", "act": { "sub": "trip-orchestrator" } }
}{
"x-delegate-flights": { "iss": "https://idp.travel.test", "sub": "user:ana.k",
"aud": "https://flights.travel.test/mcp", "act": { "sub": "trip-orchestrator" } },
"x-delegate-hotels": { "iss": "https://idp.travel.test", "sub": "user:ravi.m",
"aud": "https://hotels.travel.test/mcp", "act": { "sub": "trip-orchestrator" } }
}Both are inside their validity window. Both name the right audience for the sub-agent that receives them. The actor is on the allow-list.
I ran this fixture this morning through the checkDelegation function in the published npm package, not my working copy. The per-upstream findings were empty for every sub-agent. The request-level result had one high-severity finding: "delegate tokens in this request name different users". The matching benign control, with both tokens naming Ana, produced no findings.
A few design choices are worth naming:
- Keyless by default. In structural mode, Bastion decodes tokens strictly but does not verify signatures, so it needs no secret. Signature mode is opt-in, accepts only asymmetric algorithms, and rejects
HS256andnonein any casing. - Prior actors are a warning, not a block. RFC 8693 says the consumer must only consider the token's top-level claims and the current actor. Earlier actors in the chain are "informational only". So chain-depth and chain-cycle findings warn by default, and the benchmark caps their score at detect. A tool gets no credit for blocking on them.
- The audit record never holds the token. It records an HMAC pseudonym of the user, the issuer, audience, expiry, actors and the findings.
What I measured
The benchmark and the strongest tool it measures share an author: me. So for this family I changed the order of work. The five vectors, 15 development fixtures and 15 held-out fixtures were registered under a git tag on 15 October, before any measured defender had delegation code. I wrote Bastion's checks from the development fixtures only, froze them, and ran the held-out set once.
Results at rubric v0.9.0, development β held-out:
- mcp-bastion 1.2.0: 93% β 97% across the five delegation vectors. Four of the five are 100% on both sets. Chain laundering is 67% β 83%, partly because of the detect-only cap.
- mcp-firewall 0.1.0, pipelock 3.0.0 and the null baseline: 0% β 0%. None of them parses delegate tokens.
- False positives: zero on the matched benign controls, on both sets.
Adding the family did not move anything else. On the 32 vectors published before it, Bastion is unchanged at 62%, and every earlier case outcome is identical for every tool.
What is still open
The honest limit first, and it is a big one. A held-out score above the development score does not show that the checks generalise. It shows the held-out set was not harder for this tool. I knew which properties a defender should check when I wrote both the fixtures and the code, and pre-registration fixes the order of work, not my knowledge. The README says the same thing next to the number.
Beyond that:
- No outside comparison yet. One other tool that does parse delegate tokens was measured and then removed before publication out of licence caution. It is logged as a deviation in the pre-registration. So today the comparison is against tools that were never designed for this layer.
- Synthetic deployments. All 30 fixtures use invented travel, claims and freight deployments. I have not run the checks against a production identity provider's tokens.
- Opaque tokens. Structural checks need a JWT. If your sub-agents receive opaque tokens, the gateway can only check what introspection tells it.
- Policy is yours. Allowed actors and maximum lifetimes are deployment choices. A wrong allow-list passes cleanly.
How you can check it yourself
- Draw your own delegation path. For each sub-agent, write down which token it receives, who issued it, and which audience it names. Then ask: which component sees all of them at once?
- Send a request where two delegate tokens name different users. See whether anything in your stack refuses it.
- Read docs/DELEGATION-PREREG.md in mcp-defense-bench and compare the registered fixtures with the published ones; the manifest carries a sha256 for each.
- Run the five
delegation-*fixture folders against your own gateway. Each fixture carries its benign twin. - If your defence is written in Python, the
mcp-defense-benchpackage on PyPI (0.2.0) scores it on the same corpus, pinned to the v0.9.0 release, with the delegation family reported as its own total.
Sources
- RFC 8693, OAuth 2.0 Token Exchange, Β§4.1 "act" (Actor) Claim: https://www.rfc-editor.org/rfc/rfc8693.html#section-4.1
- MCP specification 2026β07β28, Security Best Practices, Token Passthrough: https://modelcontextprotocol.io/specification/2026-07-28/basic/security_best_practices
- mcp-defense-bench v0.9.0 (delegation pre-registration, fixtures, results, leaderboard): https://github.com/Gowthaman90/mcp-defense-bench
- mcp-bastion v1.2.0, CHANGELOG and src/security/delegation.ts: https://github.com/Gowthaman90/mcp-bastion
- mcp-bastion on npm: https://www.npmjs.com/package/mcp-bastion
- mcp-defense-bench for Python on PyPI: https://pypi.org/project/mcp-defense-bench/
Gowthaman Arumugam is an AI architect working on enterprise agent platforms and open-source MCP security tooling. More at gowthaman90.github.io.