Skip to content
Harness Engineering2026-09-107 min readRev. 2026-09-30

Reject an MCP Token That Was Issued for a Different Server

A signed, unexpired, correctly scoped token can still be meant for another server. Check audience, expiry and scope as separate predicates before the handler runs, and test each one with a positive control.

Key takeaways

  • The MCP authorization spec requires a server to validate that an access token was issued for it, not only that it is signed and unexpired.
  • A token for one server accepted by another lets anyone who holds the first reach the second, which is the audience failure the spec names.
  • Audience, expiry and scope are separate predicates; test each one alone, with the others valid, and keep a positive control.
  • A server that calls an upstream API gets its own token for that call and never forwards the client's.
  • The local fixture proves the predicate order; only a test against your real server and authorization server proves the deployment.

Rod Rivera

Author

Reject an MCP Token That Was Issued for a Different Server

Rod's note: read with a pencil; the margins are for you.

Two servers, one authorization server

Picture two MCP servers on a research team. evals-mcp lets agents read eval results. cluster-mcp lets agents launch training and eval jobs, which spends GPU hours from a shared budget. Both trust the same authorization server, so both can verify the same signatures.

An agent holds a token issued for evals-mcp. It is correctly signed, unexpired and carries a jobs:launch scope because someone was generous when they set up the client. The agent, or anyone who copied the token from a log, sends it to cluster-mcp. Should cluster-mcp launch the job?

No. A valid signature proves the claims were not altered. It does not prove this server is among the token's intended recipients. The audience claim has to name the server that is receiving the request.

What the spec requires

The MCP authorization specification (version 2025-11-25) applies to HTTP transports and builds on OAuth 2.1. Four of its requirements carry this article:

  • Clients must send the resource parameter from RFC 8707, naming the server they intend to use the token with, in both the authorization and the token request.
  • Servers must validate that a token was issued specifically for them as the intended audience, and reject tokens that do not include them.
  • Invalid or expired tokens must get HTTP 401. A valid token with too little scope should get HTTP 403 with an insufficient_scope error.
  • A server that calls an upstream API acts as a separate OAuth client there. It must not pass through the token it received.

The spec names both failure modes. A server that skips the audience check accepts tokens minted for other services. A server that also forwards those tokens downstream turns into a confused deputy: the downstream API may trust a token as if the MCP server had vouched for it. Checking the caller's authority, not the service account's covers that deputy from the application side.

Four predicates, one table

A bearer token proves possession under its validation rules, not who is holding it. Before trusting any claim in a signed token, verify the algorithm, the issuer and the key. Then answer four separate questions:

CheckQuestion it answersFailure if skipped
Signature and issuerDid a trusted issuer sign these claims unaltered?Forged tokens accepted
AudienceWas this token issued for this server?A token for one server works against another
ExpiryIs it still inside its validity window?Stale credentials keep working
ScopeDoes it permit this operation?A read-only token launches jobs

Some access tokens are opaque and are validated by asking the issuer rather than by checking a signature locally. Do not assume every OAuth access token is a JWT. And some profiles allow several audiences in one token; the rule is that this server must be one of them.

A fixture you can run

The claims below stand in for a token whose signature and issuer a separate step has already verified. Never build them from unverified request JSON in a real server. The clock is fixed so the output is the same on every run:

python
from dataclasses import dataclass

NOW = 1_000_000  # a fixed clock, so the output is the same on every run


@dataclass(frozen=True)
class Claims:
    audience: frozenset  # a token may name several recipients
    scope: frozenset
    expires_at: int


SERVER = "https://cluster-mcp.lab.example"
launched = []


def check(claims, required_scope, now=NOW):
    # Assumes signature and issuer were already verified by a separate step.
    if SERVER not in claims.audience:
        return 401, "token not issued for this server"
    if claims.expires_at <= now:
        return 401, "token expired"
    if required_scope not in claims.scope:
        return 403, f"insufficient_scope: needs {required_scope}"
    return 200, "ok"


def launch_job(claims, spec):
    status, reason = check(claims, "jobs:launch")
    if status == 200:
        launched.append(spec)  # the protected work: spending GPU hours
    return status, reason


cases = {
    "wrong audience": Claims(frozenset({"https://evals-mcp.lab.example"}), frozenset({"jobs:launch"}), NOW + 3600),
    "expired": Claims(frozenset({SERVER}), frozenset({"jobs:launch"}), NOW - 10),
    "missing scope": Claims(frozenset({SERVER}), frozenset({"jobs:read"}), NOW + 3600),
    "positive control": Claims(frozenset({SERVER}), frozenset({"jobs:launch"}), NOW + 3600),
}
for name, claims in cases.items():
    before = len(launched)
    status, reason = launch_job(claims, {"gpus": 8})
    print(f"{name:16} {status} {reason:38} jobs launched +{len(launched) - before}")
text
wrong audience   401 token not issued for this server       jobs launched +0
expired          401 token expired                          jobs launched +0
missing scope    403 insufficient_scope: needs jobs:launch  jobs launched +0
positive control 200 ok                                     jobs launched +1

I ran it with Python 3.12 on September 30, 2026; it pastes into a Colab cell. Each denial case changes exactly one claim and keeps the other two valid. That is the design choice worth copying. A token that is both expired and for the wrong server is refused at the first check and tells you nothing about whether the expiry check works. The positive control is what makes the three zeros mean something: a validator that refused everything would also produce three zeros, and only the +1 row rules that out.

Where the token checks sit before the handler. Request with token; Signature and issuer; Audience names this server; Not expired; Scope covers the operation; Handler runs
Where the token checks sit before the handler

The diagram is the full server boundary. The fixture covers only the audience, expiry and scope part. A real library may combine several checks in one call, so test behavior, not function layout.

Failure of audience, expiry or scope denies protected work; passing these predicates still leaves application permissions to check.. no; yes; Authenticated claim set; Audience, expiry and scope pass?; Deny before protected operation; Check application permissions
Failure of audience, expiry or scope denies protected work; passing these predicates still leaves application permissions to check.

The figure's last box matters. A token that passes all four checks says this server may accept requests with this scope. It does not say that this particular job, on this particular budget, is allowed. The application's own permission check still runs.

Passthrough is the failure mode, not an edge case

The tempting shortcut is to forward the client's token to a downstream API because it was "already validated." The spec forbids it for a reason: the token was validated for one boundary and never checked against the next one it crosses. Each server obtains its own token for its own upstream calls.

Write the server's token contract

List the server's canonical URI (its audience value), the scope each tool requires, the maximum token lifetime you accept, and how an early revocation reaches the server.

Test one failure at a time

Wrong audience, expired, missing scope: each with the other claims valid, each asserting that the protected counter did not move.

Keep the positive control in the same run

One fully valid token must do the protected work exactly once.

Repeat against the deployed server

Mint real test tokens from your authorization server for a second test resource and replay them. The fixture proves your predicate order; only this step proves your configuration.

Quick check — cluster-mcp receives a token that a trusted issuer signed, that has not expired, and that carries jobs:launch. Its audience is evals-mcp. What should cluster-mcp do?

Check the decision

Why this matters in a lab

Research teams wire agents to internal services: eval stores, job schedulers, dataset buckets, model registries. MCP over HTTP is one common way to do it, and every one of those servers is a place where one token can be replayed against another. The two servers in this article differ in consequence: one reads results, the other spends compute. That asymmetry is typical, and it is why the cheaper server's tokens must not work on the expensive one.

In an interview, "how does an MCP server decide whether to trust a request" has a short strong answer: validate the token's signature and issuer, check that its audience names this server, check expiry and scope, return 401 or 403 as the spec says, then run the application's own permission check. Never forward the client's token upstream. Chapter 14 builds the client side of MCP over stdio. This page covers the part that chapter leaves out on purpose: authorization for servers you reach over a network.

What would prove this wrong

If your deployed server accepts the wrong-audience token, it fails the spec's requirement. Check the configured audience value first (trailing slashes and scheme case are common mismatches; the spec recommends the form without a trailing slash), then any middleware that skips validation on some routes, before you suspect the crypto library.

None of the numbers above describe a real server. They describe four inputs to one Python function. Revocation is a fifth question this fixture does not answer: a token can be correctly audienced, unexpired and scoped, and still need rejecting because it was revoked early. Whether that works depends on the revocation mechanism your deployment declares, short lifetimes or an introspection call, and it needs its own test.

Read Chapter 14: the protocol connects, the application decides

Build a bounded MCP client where discovery describes tools and the application decides which may run.

Learn the mechanism. Test the claim.

Join the Prof Rod newsletter for one educational lesson a week, with worked examples attached. It is free to register for and separate from the Zero Employee community.