Validating Token Audience in MCP Servers

A token that is valid is not the same thing as a token that is valid for your server. The difference is the audience, and the MCP authorization specification is blunt about it: servers must validate that every access token was issued specifically for them, and must reject tokens that were not. Skip this check and a token minted for any other service at the same identity provider works against your MCP server too, which is exactly the kind of finding that ends a security review early.

This tutorial covers both halves of audience binding: asking for bound tokens with RFC 8707 resource indicators, and enforcing the binding on every request. Examples are TypeScript with the jose library, but the checks are the same in any language.

Why audience validation exists

Without audience binding, tokens are bearer instruments with no destination written on them. Two failure modes follow:

  • Replay across services. A token issued for https://calendar.example.com is presented to your MCP server, and your server accepts it because the signature and expiry check out. Whoever holds a token for any resource now holds a token for yours.
  • Passthrough downstream. Your server forwards the client's token to your own API, which accepts it for the same reason. The MCP security best practices document forbids this pattern outright: the downstream system can no longer tell an agent from a user, and revocation stops meaning anything.

Audience binding closes both: the token names its one legitimate destination, and every destination checks the name.

Request audience-bound tokens

The resource parameter

RFC 8707 defines a resource parameter that the client sends in both the authorization request and the token request, carrying the canonical URI of your MCP server. MCP clients are required to send it, so your job on the server side is to publish the exact URI you will later expect, and to configure the authorization server to honor it:

GET /authorize?response_type=code
    &client_id=agent-client
    &code_challenge=E9Mel...&code_challenge_method=S256
    &resource=https%3A%2F%2Fmcp.example.com%2Fmcp

The authorization server then issues a token whose aud claim is https://mcp.example.com/mcp. Identity providers differ here: some honor resource natively, some map it through API or application identifiers, and some need a small bridge in front of them. Confirm the aud claim in a decoded test token before writing any validation code, because the string you see there is the string you must require.

Enforce it on every request

Verify signature, then claims

Validation happens on every MCP request, before any tool dispatch. With jose, most of the checks collapse into one call, and the important part is refusing to proceed unless all of them pass:

import { createRemoteJWKSet, jwtVerify } from "jose";

const ISSUER = "https://auth.example.com";
const AUDIENCE = "https://mcp.example.com/mcp";
const jwks = createRemoteJWKSet(
  new URL(`${ISSUER}/.well-known/jwks.json`),
);

export async function requireUser(authHeader: string | undefined) {
  if (!authHeader?.startsWith("Bearer ")) {
    throw new AuthError("missing_token");
  }
  const token = authHeader.slice("Bearer ".length);
  const { payload } = await jwtVerify(token, jwks, {
    issuer: ISSUER,
    audience: AUDIENCE, // exact match against aud
  });
  return {
    userId: String(payload.sub),
    tenantId: String(payload.org_id ?? ""),
    scopes: String(payload.scope ?? "").split(" "),
  };
}

The identity object this returns is what every tool handler receives. User and tenant come from the verified claims, never from tool arguments, which is how audience validation connects to per-tenant scoping.

Never forward the inbound token

When a tool needs to call a downstream API, the inbound token stops at your server. Obtain a separate credential for the downstream call: a service credential that carries the user's identity as a claim the downstream API understands, or a standards-based token exchange that issues a properly scoped downstream token. If you find the client's token in an outbound Authorization header anywhere in your codebase, that is the finding to fix first.

Common failure modes

The gaps we see most often when reviewing servers that "already do OAuth":

FailureWhat a reviewer seesFix
No aud checkTokens for other services are acceptedRequire an exact match on your canonical URI
Wildcard or multi-audience acceptanceOne token works everywhereAccept exactly one audience per server
Audience checked once per sessionLater requests skip validationValidate on every request; sessions are not credentials
Inbound token forwarded downstreamPassthrough to internal APIsSeparate downstream credential or token exchange
aud mismatch with published resourceDiscovery and validation disagreeUse one canonical URI constant for both

Test it

Three requests tell you whether the binding is real:

# A valid token for this server: expect 200
curl -si -X POST https://mcp.example.com/mcp \
  -H "Authorization: Bearer $GOOD_TOKEN" | head -1

# A valid token for a DIFFERENT resource: expect 401
curl -si -X POST https://mcp.example.com/mcp \
  -H "Authorization: Bearer $OTHER_SERVICE_TOKEN" | head -1

# An expired token: expect 401 with error="invalid_token"
curl -si -X POST https://mcp.example.com/mcp \
  -H "Authorization: Bearer $EXPIRED_TOKEN" | grep -i www-authenticate

The second test is the one that matters and the one almost nobody runs. Mint a real token for another API in the same identity provider and present it; if you get anything other than a 401, the audience check is not doing its job. We run exactly this replay test, along with cross-tenant and passthrough attempts, in every MCP security audit, and design the seam for token exchange into every server we build.