Skip to content
Harness Engineering2026-09-1012 min read

MCP Token Audience Validation: A Worked Denial Case

Validate an MCP token for the receiving service as well as its issuer, expiry and scope. Test wrong-audience denial before protected work executes.

Key takeaways

  • Signature validation protects the authenticity of signed claims; the receiver must still check whether the audience claim authorizes this service.
  • MCP token audience validation must run as a separate predicate from signature checks, before any handler executes.
  • Checking a token signature alone leaves audience, validity and action permissions unresolved, even when the signing key is trusted.
  • Testing wrong audience, expired token, and missing scope as three separate cases catches failures that one combined test hides.
  • A request counter proves only that your local fixture rejected early; it says nothing about a real supplier's enforcement.

Rod Rivera

Author

MCP Token Audience Validation: A Worked Denial Case

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

The token that works everywhere is the problem

Consider a hypothetical pair of services. You issue a token for a client talking to inventory-api. The token is correctly signed, hasn't expired, and carries a scope that permits reading stock levels. Now suppose that same token shows up in a request to supplier-api, a completely different service that happens to trust the same signing key. Does supplier-api have to accept it?

The receiving service should deny that request. A valid signature establishes that the signed claims have not been altered under the signing key being verified. It does not establish that this receiver is among the token's permitted audiences. The audience claim must be checked against the service actually receiving the request.

The MCP authorization specification requires a protected resource server to validate that access tokens were issued for its use. This article isolates that decision with synthetic claim objects. It does not implement a token format, cryptographic verifier or identity provider.

MCP token audience validation is one part of the boundary

Start by naming the parts, because the vocabulary is where confusion usually begins.

A bearer token establishes possession of a credential under its validation rules; possession is not independent proof of which human sent the request. With a signed token, verify the permitted algorithm, trusted issuer and signing key before relying on its claims. A token can be stolen, expired or intended for another resource even when its signature verifies.

Authorization is a bundle of separate questions, and audience is only one of them:

CheckQuestion it answersTypical failure if skipped
Signature validityWas this token issued by a trusted party and left untampered?Forged or corrupted tokens accepted
AudienceWas this token meant for this service?A token minted for one service works against another
ExpiryIs this token still within its validity window?Stale credentials keep working indefinitely
ScopeDoes this token permit the specific action being attempted?A read-only token performs a write

The checks form a complete validation path for the declared token profile, followed by application permission checks. Signature verification alone leaves the other rows unresolved. Opaque tokens may require an issuer-provided validation mechanism instead of local signature verification; do not assume every OAuth access token is a JWT.

This is the same distinction the permission-boundary teaching case makes about Claude Code's rule enforcement: the mechanism that decides what a system attempts is different from the mechanism that decides what it's allowed to do, and conflating the two is the recurring, costly mistake (Your Claude Code Permissions Are Your Org Chart). There the split was between model behavior and enforced rules. Here it's between signature validity and audience binding. Same shape of error, different layer of the stack.

Tracing the check through a mock validator

Here's a small, deliberately bounded example. This is a teaching fixture, not a production authorization library, and nothing here proves how any real token issuer or receiver behaves. It shows audience, expiry and scope predicates using Python's standard library. The input represents claims already authenticated by a separate boundary. Never construct it directly from unverified request JSON in a real server. The short service labels stand in for the resource identifiers your deployment actually uses.

python
from dataclasses import dataclass
from time import time

@dataclass
class MockToken:
    audience: str
    scope: set
    expires_at: float

class Denied(Exception):
    pass

def validate(token: MockToken, expected_audience: str, required_scope: str, now=None):
    now = now if now is not None else time()
    # Each predicate below is checked independently. Any single failure
    # stops processing before a handler is ever reached.
    if token.audience != expected_audience:
        raise Denied(f"audience mismatch: token for {token.audience!r}, "
                      f"server is {expected_audience!r}")
    if token.expires_at <= now:
        raise Denied("token expired")
    if required_scope not in token.scope:
        raise Denied(f"missing scope: {required_scope!r} not in {token.scope}")
    return True

protected_calls = 0

def read_handler(token):
    global protected_calls
    validate(token, expected_audience="supplier-api", required_scope="read")
    protected_calls += 1
    return "stock levels: [mock data]"

# Case 1: token minted for inventory-api, presented to supplier-api
wrong_audience = MockToken(audience="inventory-api",
                           scope={"read"}, expires_at=time() + 3600)
try:
    read_handler(wrong_audience)
except Denied as e:
    print("denied:", e)

print("protected_calls after wrong-audience case:", protected_calls)

Running this by hand: validate checks token.audience != expected_audience first. Since "inventory-api" != "supplier-api", it raises Denied immediately. protected_calls never increments, because the raise happens before the line that would increment it. The printed output reads:

denied: audience mismatch: token for 'inventory-api', server is 'supplier-api' protected_calls after wrong-audience case: 0

That zero is the entire point of the exercise, and it's worth being precise about what it does and doesn't establish. This is an in-memory counter inside one Python process. It shows that the increment and protected return value were not reached for this input. The route function did execute far enough to call validation and raise the denial. It proves nothing about how any actual supplier's server handles a similarly shaped token, because a real service's internal logging, database writes, and downstream calls are outside this process entirely. A local mock passing this test is evidence about the mock, not a receipt for any external system's production behavior.

Where audience validation sits before the handler

The diagram specifies the broader server boundary. The Python fixture implements only the audience, expiry and scope portion; signature and issuer verification must happen before its input is trusted. An actual library may combine several checks in one call, so test the resulting behavior rather than requiring a particular function layout.

Building the second and third denial cases

Vary one claim at a time. An expired token with the wrong audience would be rejected at the first check and tell you nothing about expiry enforcement. The control table keeps unrelated conditions valid so each failure has an identifiable cause.

CaseAudienceExpiryScopeExpected protected work
Wrong audienceInventory serviceFutureReadNone
ExpiredSupplier servicePastReadNone
Missing scopeSupplier serviceFutureWrite onlyNone for a read request
Positive controlSupplier serviceFutureReadOne call

Failure of audience, expiry or scope denies protected work; passing these predicates still leaves application permissions to check.

Test the wrong-audience case

Present a token whose audience field names a different service than the one receiving the request. Confirm denial happens at the audience check, before expiry or scope are even examined, and confirm the handler's call counter stays at zero.

Test the expired-token case

Take a token with the correct audience and correct scope, but set expires_at to a time in the past. Confirm the validator denies on the expiry predicate specifically, not on audience, and again confirm zero handler calls.

Test the missing-scope case

Take a token with the correct audience and a valid expiry, but omit the required scope from its scope set — for example, a token scoped only for "read" presented against a handler that requires "write". Confirm denial happens on the scope predicate.

Running the expired case against the code above: build a token with audience="supplier-api", scope={"read"}, but expires_at=time() - 10. The audience check passes silently, because "supplier-api" == "supplier-api". The expiry check then evaluates token.expires_at <= now, which is true, so Denied("token expired") raises. protected_calls stays at zero for a different reason than in the first case. That's the value of testing these separately: a server that only ever saw the audience-mismatch case might have wired its expiry check incorrectly and nobody would notice until an expired-but-audience-correct token walked through in production.

The missing-scope case works the same way structurally. Build a token with correct audience, a future expiry, but scope={"write"} when the handler demands "read" — or the reverse. The first two predicates pass; the scope predicate fails; protected_calls again stays at zero, this time because of the third independent check.

A positive control matters too, and it's easy to skip. Build one token with the exact expected audience, an unexpired timestamp, and the permitted scope, then confirm the read handler executes and protected_calls increments to 1. Without that control, a validator that denies everything would pass all three denial tests trivially, which would prove your validator does nothing useful rather than proving it does the right thing.

Passthrough is the failure mode, not an edge case

The tempting shortcut is forwarding a client's token unchanged to a downstream service because it's "already validated." That reintroduces exactly the audience problem this article is about: a token validated for one boundary was never checked against the next one it crosses. Each service in a chain has to validate audience for itself.

What falsifies this and what doesn't

It's worth being explicit about the boundary of what this worked example demonstrates, because it's narrow on purpose. The example shows that a correctly written validator, given these four inputs, denies three of them and admits one, inside a single Python process, with no network calls and no persistent storage. That's a toy in-memory example. It has no bearing on concurrency behavior under load, on what happens when two requests race against a token revocation, or on how any specific vendor's token-issuing service actually populates the audience field in the tokens it mints.

A server accepting the wrong-audience case would fail the stated resource-binding requirement. Check the configured expected audience, the token profile's handling of an audience list and any middleware bypass before assuming the cryptographic library is faulty. Some token profiles legitimately name several recipients; the required check is that this receiving resource is authorized, not that every token contains exactly one audience string as in this fixture. Replay after revocation is a related but separate question, and this article does not resolve it. A token can be within its stated expiry window, correctly audienced, and correctly scoped, and still need rejecting because it was revoked early. That depends entirely on whatever revocation mechanism a given deployment declares — a check against a revocation list, a short-lived token with frequent reissue, or something else — and none of those mechanisms are established just because signature, audience, expiry, and scope all passed. Treat revocation as a fifth predicate to specify explicitly, not as something signature validation happens to cover.

Quick check — A tool server receives a token that is correctly signed by a trusted issuer, has not expired, and is scoped for write access. It was issued for inventory-api and the request is being made to supplier-api. What should the server do?

Check the decision

Where this leaves the handover

If you're the one specifying a tool server's authorization boundary rather than writing the validator code, the deliverable is a short document, not a paragraph of intentions: which resource each token is bound to, which scopes each of your server's tools requires, and which mechanism revokes a token early if that ever needs to happen. That document is what someone else tests against with the three-case pattern above — wrong audience, expired, missing scope — plus the positive control, before any of this touches a credential that isn't a fixture value.

None of the code in this article has been run against a real MCP server or a real identity provider, and none of the counts above are claims about production behavior anywhere except inside the small Python program shown. Treat the fixture as a specification check, confirm your actual server implements the same four independent predicates, and verify how the authenticated request maps to application permissions for the particular operation. A signature, or any one predicate in this fixture, is insufficient on its own.

Read the permission-boundary case this argument extends

See how the same split between what a system attempts and what it is actually allowed to do plays out in Claude Code's own rule enforcement.

Ready to put an agent to work?

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.