HomeJournalThis post

MCP OAuth Audience Validation Guide

A deployment-level guide to carrying one MCP resource identifier through discovery, OAuth issuance, gateway policy, and exact token audience validation.

JP
JP Casabianca
UI/UX designer and full-stack engineer · Bogotá

A bearer token accepted by the wrong MCP server turns one authorized tool call into a lateral-movement primitive. MCP OAuth audience validation closes that path by proving, on every request, that the token was minted for this exact protected resource.

MCP OAuth audience validation is small in code; choosing and preserving the resource identifier across discovery, authorization, token issuance, and verification is the real engineering work. The review connects OAuth resource indicators with MCP protected resource metadata and MCP bearer token validation.

MCP OAuth audience validation is the boundary

An MCP server may sit behind the same gateway, authorization server, and DNS name as many ordinary APIs. That topology does not make their bearer tokens interchangeable. A token proves a delegation from an issuer to a client for a recipient; if the recipient does not check that it is named, any service that can receive the token may become an unintended replay destination. A broad scope such as mcp:tools does not identify that destination. Scope answers what a token may do. Audience answers where it may be used.

The MCP 2026-07-28 specification release makes this especially operational. Requests are self-describing and can land on any stateless instance, while gateways can route on Mcp-Method and Mcp-Name. That removes transport affinity, but it does not remove resource identity. Every instance behind the route must enforce the same issuer, resource identifier, and audience policy before it trusts those headers or parses a tool call.

Treat the check as a local invariant: the validated token audience contains the configured identifier of the MCP protected resource. Do not derive the expected value from Host, X-Forwarded-Host, a request body, or a client-supplied discovery URL. Those are attacker-influenced routing inputs. Configuration can be deployed and reviewed; request data cannot define its own trust target. This distinction makes audience validation work under round-robin balancing, because the proof belongs to the resource rather than to a session or machine. MCP OAuth audience validation preserves that proof.

One resource identifier carried through MCP OAuth A five-stage chain carries the exact protected resource identifier from the MCP endpoint through discovery, authorization, the access token audience, and request verification. A separate mismatched token stops before tool dispatch. MCPendpoint resourcemetadata resourcerequest tokenaudience MCPverifier https://mcp.example.com/tenant/acme at every stage wrong audience stop
  1. Protected resource
  2. Metadata identity
  3. OAuth resource
  4. Token audience
  5. Request verifier
Figure 1: The identifier is a continuity proof, not a hostname hint. The diagram implies that a verifier can reject a redirected token locally, before any MCP method or tool name reaches application code.

Choose a resource identifier that survives deployment

The identifier should be an HTTPS URL without a fragment and should normally avoid a query string. It is an identity first; it may also be a network location, but the verifier must compare it to the declared value rather than improvise normalization rules. Decide whether one MCP deployment is one resource or whether paths identify independent resources such as https://mcp.example.com/tenant/acme. The latter is appropriate when a token accepted for one tenant must never work for another. A shared origin is not a shared security boundary.

RFC 9728 protected resource metadata defines a metadata document whose required resource member names the protected resource and whose optional authorization_servers array advertises issuers that can serve it. Publish the document from the well-known location derived from that identifier. Then validate that the returned resource equals the identifier the client set out to reach. Discovery is not permission to substitute whatever identity a downloaded JSON document claims.

Reverse proxies deserve an explicit mapping. Write a small deployment table with the public resource identifier, internal route, allowed issuer, and tenant boundary. Keep the public identifier stable when containers, regions, or internal service names change. If a public identifier must change, treat that as an authorization migration: publish new metadata, configure the authorization server, issue tokens for the new resource, dual-read only for a bounded window if necessary, then remove the old audience. Quietly accepting both forever turns a migration aid into a permanent expansion of token reach. MCP OAuth audience validation makes that migration testable.

Carry the same resource through the OAuth exchange

Once the client knows the protected-resource identifier, it sends that value as the OAuth resource parameter during authorization and token requests. RFC 8707 resource indicators gives the authorization server enough context to issue an access token restricted to the intended service. The server can reject an unknown or malformed target with invalid_target, apply resource-specific policy, and express the restriction through a JWT aud claim or token-introspection response.

Use the most specific identifier that matches the actual boundary. Requesting only https://mcp.example.com when the server protects /tenant/acme gives the issuer no way to preserve tenant separation. Conversely, inventing a per-tool resource such as /tools/delete-project confuses audience with capability and creates discovery churn. Tools and methods belong in scope or policy; the audience should name the protected resource that receives the bearer token.

Prefer one resource per token. RFC 8707 permits multiple resource parameters and therefore tokens with multiple intended recipients, but it notes that every recipient must be trusted not to reuse the bearer token at the others. That is rarely the useful default for agent infrastructure. If a workflow calls three MCP servers, obtain three audience-restricted tokens. The field note on AI agent workload identity shows how to keep each service delegation bounded without creating a universal agent credential. Log the resource requested and the audience issued as identifiers, never the access token itself. MCP OAuth audience validation then has one unambiguous target.

Requested MCP resourceToken audExact result
https://mcp.example.com/tenant/acmehttps://mcp.example.com/tenant/acmeAccept
https://mcp.example.com/tenant/acmehttps://mcp.example.comReject: too broad
https://mcp.example.com/tenant/acmehttps://mcp.example.com/tenant/zephyrReject: wrong tenant
https://mcp.example.com/tenant/acmehttps://api.example.comReject: redirected token
Figure 2: Same origin does not mean same protected resource. The worked cases imply that origin-only comparison silently erases path-based tenant boundaries, while exact configured identifiers preserve them.

Validate before any MCP capability is dispatched

Audience is one stage in complete token validation, not a replacement for it. A resource server first verifies the signature with keys bound to the configured issuer and permits only expected algorithms. It checks iss, expiration, not-before time when present, and any deployment requirements for client or subject. It then reads aud as either a string or an array and requires its own exact configured identifier. Only after those identity checks pass should it intersect scopes and application policy for Mcp-Method, Mcp-Name, tenant, and tool arguments.

Exact means exact according to the issuer-resource contract. Avoid ad hoc conveniences such as lowercasing paths, discarding a trailing segment, matching URL prefixes, or comparing origins. URL syntax has rules, yet security identifiers still need an agreed serialization. The safest operational choice is to configure one canonical resource string at the client, authorization server, metadata endpoint, and verifier, then test those four surfaces together. If an issuer deliberately maps a resource indicator to an abstract audience value, configure that mapping explicitly and document it; do not make the resource server guess.

Failure should produce an OAuth-shaped denial before tool dispatch. Return a 401 challenge when credentials are missing or invalid, include the protected-resource metadata location when your flow uses that discovery mechanism, and keep the body generic. Internally, record a bounded reason such as issuer_mismatch, audience_missing, or audience_mismatch, plus an opaque request identifier. Prompt-injection defenses for tool agents should state that model-controlled content cannot broaden an audience or copy bearer tokens between tool servers when authorization fails. MCP OAuth audience validation remains earlier than tool policy.

  1. 1Resolve policy

    Load the configured resource identifier and allowed issuer; never infer either from forwarded request headers.

  2. 2Verify token

    Validate signature, algorithm, issuer, time claims, and required audience with one bounded verifier.

  3. 3Intersect scope

    Apply MCP method and tool policy only after token identity is valid for this protected resource.

  4. 4Dispatch or deny

    Return a challenge on failure and emit a reason code without logging the bearer token.

Figure 3: Audience validation precedes capability authorization. The sequence implies that a valid scope cannot rescue a token minted for another MCP server, and a valid audience cannot grant an unapproved tool.

Keep discovery and verification in separate trust lanes

Protected-resource metadata helps a client learn where to authorize, while token validation tells the resource server whether a credential is acceptable. Combining those roles creates subtle loops. A server must not fetch an arbitrary issuer named by a token and trust whatever keys it returns. A client must not follow metadata from an unrelated host and send an existing bearer token there. Both sides begin with an intended protected resource and constrain discovery to that relationship.

Cache metadata carefully. RFC 9728 allows a protected resource to signal its metadata URL in WWW-Authenticate, including when configuration changes. Cache headers can reduce discovery traffic, but a cached issuer list does not override the resource identifier the client intended to access. On refresh, repeat URL, TLS, resource-member, and allowed-issuer checks before using new metadata. Keep the last known-good document only if the product has a deliberate availability policy; silently accepting new issuers for convenience changes trust.

This separation also clarifies gateway design. A gateway may terminate TLS, validate tokens centrally, and forward a signed internal identity envelope, or each MCP instance may validate the original access token. Either model can work if the trust boundary is explicit. In the centralized model, backends authenticate the gateway and reject direct traffic. In the distributed model, every instance shares issuer and audience configuration and verification tests. The guide to MCP tasks for long-running tools is a useful place to carry the same resource boundary through task creation, polling, and result retrieval. MCP OAuth audience validation must remain identical across those operations.

Prove the verifier with hostile fixtures

A successful login is weak evidence for audience enforcement because most happy-path clients request the correct target automatically. The useful test matrix changes one boundary at a time: exact audience versus host-only audience, sibling tenant, unrelated API, missing aud, string versus array form, extra audience, wrong issuer, expired token, early token, and missing tool scope. Each failure should stop before an MCP handler observes the request.

Make the clock injectable and the resource identifier immutable so boundary tests stay deterministic. Keep cryptographic verification in a mature JOSE or OAuth library in production; the artifact below starts after signature verification and isolates claim-policy decisions that teams often accidentally weaken in wrapper code. Its local rule rejects multiple audiences. That is stricter than baseline interoperability, but it makes the least-privilege choice visible and can be relaxed only through reviewed configuration.

Repeatability matters because policy helpers often acquire hidden dependencies on request headers, process environment, or wall-clock time. Given frozen claims and policy, two evaluations should return the same immutable authorization context. That context may proceed to method and tool authorization, but downstream code should never receive raw unverified claims. The same approach complements durable AI agent execution: recovery controls repeated effects, while audience validation controls which protected resource may consider the request at all. MCP OAuth audience validation should stay pure under every replay.

Runnable artifact: The MCP OAuth audience validation fixture exercises happy, boundary, failure, and repeatability behavior with nine explicit assertions. It intentionally models claim enforcement rather than JWT signature parsing, so the policy remains runnable with stock Node and easy to place behind the project's verified-token adapter.

Save this as mcp-audience-validation.mjs and run node mcp-audience-validation.mjs. Expected final line: PASS: 9 MCP audience validation assertions.

import assert from "node:assert/strict";

const deny = (code) => Object.assign(new Error(code), { code });

export function validateMcpAccessToken(claims, policy) {
  if (!claims || typeof claims !== "object") throw deny("invalid_token");
  if (claims.iss !== policy.issuer) throw deny("issuer_mismatch");

  const audiences = typeof claims.aud === "string"
    ? [claims.aud]
    : Array.isArray(claims.aud) && claims.aud.every((item) => typeof item === "string")
      ? claims.aud
      : [];
  if (audiences.length === 0) throw deny("audience_missing");
  if (!audiences.includes(policy.resource)) throw deny("audience_mismatch");
  if (policy.requireSingleAudience && audiences.length !== 1) {
    throw deny("multiple_audiences_disallowed");
  }

  const now = policy.now();
  const skew = policy.clockSkewSeconds ?? 0;
  if (!Number.isFinite(claims.exp) || now - skew >= claims.exp) throw deny("token_expired");
  if (claims.nbf !== undefined && (!Number.isFinite(claims.nbf) || now + skew < claims.nbf)) {
    throw deny("token_not_active");
  }

  const scopes = new Set(String(claims.scope ?? "").split(/\s+/).filter(Boolean));
  for (const required of policy.requiredScopes ?? []) {
    if (!scopes.has(required)) throw deny("scope_missing");
  }
  return Object.freeze({ subject: claims.sub, audience: policy.resource, scopes: [...scopes].sort() });
}

const policy = Object.freeze({
  issuer: "https://auth.example.com",
  resource: "https://mcp.example.com/tenant/acme",
  requiredScopes: ["mcp:tools"],
  requireSingleAudience: true,
  clockSkewSeconds: 30,
  now: () => 2_000_000_000,
});
const valid = Object.freeze({
  iss: policy.issuer,
  aud: policy.resource,
  sub: "user-7",
  scope: "profile mcp:tools",
  exp: 2_000_000_300,
});
const codeOf = (fn) => {
  try { fn(); return "accepted"; }
  catch (error) { return error.code; }
};

let assertions = 0;
const check = (fn) => { fn(); assertions += 1; };

// Happy path: the exact configured protected resource is accepted.
check(() => assert.deepEqual(validateMcpAccessToken(valid, policy), {
  subject: "user-7",
  audience: policy.resource,
  scopes: ["mcp:tools", "profile"],
}));

// Boundary: a one-element aud array is equivalent to the string form.
check(() => assert.equal(
  validateMcpAccessToken({ ...valid, aud: [policy.resource] }, policy).audience,
  policy.resource,
));

// Boundary: a token inside the declared clock-skew window remains usable.
check(() => assert.equal(
  validateMcpAccessToken({ ...valid, exp: policy.now() + 1 }, policy).subject,
  "user-7",
));

// Failure: a sibling tenant on the same host is still another audience.
check(() => assert.equal(codeOf(() => validateMcpAccessToken({
  ...valid,
  aud: "https://mcp.example.com/tenant/zephyr",
}, policy)), "audience_mismatch"));

// Failure: a host-only audience cannot stand in for a tenant resource.
check(() => assert.equal(codeOf(() => validateMcpAccessToken({
  ...valid,
  aud: "https://mcp.example.com",
}, policy)), "audience_mismatch"));

// Failure: multiple audiences are refused by this least-privilege policy.
check(() => assert.equal(codeOf(() => validateMcpAccessToken({
  ...valid,
  aud: [policy.resource, "https://api.example.com"],
}, policy)), "multiple_audiences_disallowed"));

// Failure: issuer and audience are independent boundaries.
check(() => assert.equal(codeOf(() => validateMcpAccessToken({
  ...valid,
  iss: "https://attacker.example",
}, policy)), "issuer_mismatch"));

// Failure: audience correctness does not substitute for MCP tool scope.
check(() => assert.equal(codeOf(() => validateMcpAccessToken({
  ...valid,
  scope: "profile",
}, policy)), "scope_missing"));

// Repeatability: frozen inputs produce identical, mutation-resistant output.
check(() => {
  const first = validateMcpAccessToken(valid, policy);
  const second = validateMcpAccessToken(valid, policy);
  assert.deepEqual(first, second);
  assert.equal(Object.isFrozen(first), true);
});

assert.equal(assertions, 9);
console.log("PASS: 9 MCP audience validation assertions");

Roll out with evidence from every boundary

Start in observation mode only if logging cannot expose tokens and denial is not yet safe. Compare the configured resource with the validated aud, count results by reason code and client family, and investigate every host-only or sibling-resource token. Do not label mismatches as harmless legacy traffic until you know which authorization request omitted or broadened the resource parameter. The repair usually belongs at client discovery or issuer policy, not in a permissive resource-server fallback.

Then enforce for a canary slice and watch authentication failure rate, token-refresh loops, issuer distribution, metadata fetch errors, and tool-dispatch counts. A healthy graph shows mismatched tokens denied before dispatch and correctly targeted clients recovering through ordinary token acquisition. Set a short deadline for compatibility exceptions, name an owner, and encode each exception as a specific resource mapping rather than a wildcard. Rollback should restore the previous verifier version, not disable issuer or audience checks globally.

The release packet should contain the public identifier, metadata document, allowed issuer, authorization request example, decoded test-token claims with fake values, challenge response, gateway trust model, and the runnable fixture. Reviewers can then trace one value across all surfaces. Audience validation is complete when a token for another API, another tenant, or merely the same host fails deterministically on every MCP instance, while the exact intended token survives scaling, restart, and repeat execution. That is the narrow proof a stateless MCP deployment needs: any instance may handle the request, but no other protected resource may inherit its authority. MCP OAuth audience validation is the release boundary.