Zuplo
MCP error

Token audience validation failed

The token is real — right issuer, good signature, not expired — but the `aud` claim names something other than this MCP server, so the server is required to reject it, and does.

MCP Specification
2026-07-28

Is this you?

Do not start from your configuration. Read the two strings that are actually being compared — the resource identifier your server publishes, and the aud the authorization server put in the token — and diff them by eye. That settles which side to change, which is the whole question.

TerminalRun this to check — it changes nothing
# 1. The resource identifier your server expects. This is the string its
#    audience check compares against.
curl -sS https://your-server.example.com/.well-known/oauth-protected-resource/mcp \
  | jq -r .resource
# → https://your-server.example.com/mcp

# 2. The audience the authorization server actually minted. Decode only —
#    no signature check, so never trust the output for anything but reading.
TOKEN='eyJhbGciOiJSUzI1NiIs...'
python3 -c 'import base64,json,sys
p = sys.argv[1].split(".")[1]
print(json.dumps(json.loads(base64.urlsafe_b64decode(p + "=" * (-len(p) % 4)))))' \
  "$TOKEN" | jq '{aud, iss, azp, client_id, scp, scope}'

# 3. Present it and read the challenge verbatim.
curl -sS -D - -o /dev/null -X POST https://your-server.example.com/mcp \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
This error
Step 2 prints an aud that is a GUID, an api://… identifier, account, an identifier belonging to a sibling MCP route, or the same URL as step 1 with one extra trailing slash — and step 3 returns 401 with WWW-Authenticate: Bearer error="invalid_token". No aud key at all is the same failure with less to look at: an Amazon Cognito access token has none unless a resource binding was requested, and a token minted without any resource indicator often has none either. If step 2 fails to decode, the token is opaque rather than a JWT — the audience then lives in the introspection response, not in the token, and you need your server's introspection log instead. If step 1 404s or prints a URL you do not recognise, fix that first: an audience check against a resource identifier the server invented for itself will never pass.
Then the fix is the next section.
Working
Step 2's aud contains step 1's string character for character — same scheme, same host, same path, same trailing slash or absence of one — and step 3 returns 200. An aud that is an array is fine: RFC 9068 asks only that it contain a value the resource server expects for itself, so one matching entry ends the check. An aud that is a prefix of the URL you dialed can also be fine, because some servers match hierarchically — the MCP Python SDK's example verifier does, through check_resource_allowed — but that is your server's choice, not a rule you can assume.
Then this is not your problem — try the error reference.
4 other strings clients print for this
  • invalid audience
  • Token audience validation failed - no matching audiences
  • Token resource validation failed. Expected: <resource>
  • The aud claim is not valid

Dotted spans such as <url> are placeholders — your client prints your own value there.

Fix it

Two strings have to match: the resource identifier your MCP server expects, and the aud your authorization server actually minted. Decode the token and compare them literally, trailing slash included, before changing anything. Then move whichever one is wrong. Most authorization servers do not put your MCP URL in aud by default — a Microsoft Entra ID v2.0 access token's aud is always the API's client ID, a Keycloak audience is a client ID, an Amazon Cognito access token has no aud at all unless the client asked for a resource binding, and Auth0 uses its own audience parameter unless you turn on its resource-parameter profile. Where the provider can be made to mint your canonical MCP URI, do that; where it cannot, configure your server to expect the identifier the provider does mint, one per MCP route. Turning the check off is not on the list: the 2026-07-28 spec makes it a **MUST**.

Have your agent fix this

Copies a prompt that sends your coding agent to this page, then has it find and apply the right fix for your project.

The cause depends on your platform. Pick yours.

Microsoft Entra ID

If the token came from Microsoft Entra ID and its aud is a GUID rather than your MCP URL

  1. 1 This is documented behaviour, not a misconfiguration. Microsoft's access token claims reference describes aud as "an Application ID URI or GUID", and is specific about the version split: "In v2.0 tokens, this value is always the client ID of the API. In v1.0 tokens, it can be the client ID or the resource URI used in the request." So a v2.0 access token will never carry https://api.example.com/mcp in aud, no matter what resource the client sends.
  2. 2 Which version you get is a property of the API, not of the client. The app manifest's requestedAccessTokenVersion "specifies the access token version expected by the resource… Possible values… are 1, 2, or null. If the value is null, this parameter defaults to 1", and Microsoft notes that "the endpoint used, v1.0 or v2.0, is chosen by the client and only impacts the version of id_tokens". Check that attribute before you theorise about the client.
  3. 3 The practical fix is to configure your MCP server to expect what Entra mints — the API app registration's client ID, or its Application ID URI — rather than to expect Entra to mint your canonical MCP URI. Microsoft's own guidance on the claim is the same instruction from the other side: "This value must be validated, reject the token if the value doesn't match the intended audience."
  4. 4 Do not expect the RFC 8707 resource parameter to change the outcome at the v2.0 token endpoint. An open MCP Python SDK issue reports Entra rejecting it outright on refresh with AADSTS9010010 ("The resource parameter provided in the request doesn't match with the requested scopes"), and the same issue traces a plain audience mismatch to a trailing slash: Pydantic's AnyHttpUrl renders a bare-domain resource as https://mcp-server.example.com/, while the Entra app registration holds https://mcp-server.example.com.
  5. 5 If you do want a URL-shaped audience, it has to be registered as an Application ID URI, and the format rules are strict: the supported forms include api://<appId> and https://<verifiedCustomDomain>/<string>, "the recommendation is to use api://<appId>", and "the application ID URI value must not end with a slash". That last rule and the spec's preference for the no-trailing-slash form agree, which is convenient — pick one string and use it in both places.
JSONjson
// A decoded Entra v2.0 access token. Nothing here is wrong; the aud is
// the API app registration's client ID, which is what v2.0 always emits.
{
  "aud": "11112222-bbbb-3333-cccc-4444dddd5555",
  "iss": "https://login.microsoftonline.com/aaaabbbb-0000-cccc-1111-dddd2222eeee/v2.0",
  "azp": "00001111-aaaa-2222-bbbb-3333cccc4444",
  "scp": "mcp",
  "ver": "2.0"
}
Entra ID: access token claims reference

Auth0

If the token came from Auth0 and aud is an API identifier you did not ask for, or the token will not decode at all

  1. 1 Auth0 has a documented answer for MCP specifically, and it is a tenant toggle. Its Auth for MCP guide states the mismatch plainly: "The Model Context Protocol (MCP) specification requires the use of the standards-compliant resource parameter as defined in RFC 8707. Auth0's Authentication API has historically used the audience parameter to specify a target resource server (API)." Until you change that, "the Resource Parameter Compatibility Profile will keep the experience as is and only use the audience parameter in the Auth0 access token" — your client's resource is dropped on the floor.
  2. 2 Enable it in **Settings → Advanced**: **Resource Parameter Compatibility Profile** ("enables support for the resource parameter in authorization requests, allowing access tokens to be scoped to a specific API") and, in the same block, **Include Issuer in Authorization Responses** for the RFC 9207 iss parameter clients now validate. After that, "Auth0 will use the resource parameter if it is available to define the token's audience."
  3. 3 Know the precedence before you debug the result: "If both the resource and audience are available, the audience will still be used." A client library that sets an Auth0 audience for you will silently win over the resource the MCP spec requires. Supported flows for resource are the standard authorization flow, PAR, JAR, CIBA and the refresh token grant.
  4. 4 The value has to exist as an API first, and its shape matters. An API's **Identifier** "is set upon API creation and cannot be modified afterward", and Auth0's recommendation for MCP is explicit: "RFC 8707 requires the resource parameter to be an absolute URI, to conform with RFC 8707, we recommend defining your resource server identifiers (API identifiers) in URI format." Register the canonical MCP URI as the API identifier and the two strings become the same string.
  5. 5 One caveat if you federate: with the profile enabled, "resource is not available as an upstream IdP parameter, and will not be forwarded", and Auth0 says "if passing the resource parameter to an upstream IdP, the Resource Parameter Compatibility Profile should not be enabled."
JSONjson
// What a decoded Auth0 access token should look like once the API
// identifier is the canonical MCP URI and the resource profile is on.
{
  "iss": "https://your-tenant.us.auth0.com/",
  "aud": "https://api.example.com/mcp",
  "azp": "aBcD1234...",
  "scope": "mcp",
  "sub": "auth0|68c1..."
}
Auth0: Resource Parameter Compatibility Profile

Keycloak

If the issuer is a Keycloak realm and aud is a client ID, account, or absent

  1. 1 Keycloak's audiences are client IDs, not URLs. Its audience-support documentation is direct about the model: "The claim aud should typically represent client ids of all services where the token is supposed to be used." An MCP server expecting https://api.example.com/mcp will therefore never match a default-configured realm token, and the resource parameter your client sends does not change that.
  2. 2 Understand why aud is sometimes empty. The default roles client scope carries an *Audience Resolve* mapper that "checks for clients that have at least one client role available for the current token. The client ID of each such client is then added as an audience" — so with no client roles assigned, no audience appears. And the client the token was issued *to* is never added: "the frontend client itself is not automatically added to the access token audience… since the access token will not contain the client for which the token is issued as an audience."
  3. 3 Use the *Audience* mapper instead, which does not depend on roles. "A hardcoded audience is a protocol mapper, that will add the client ID of the specified service client as an audience to the token. You can use any custom value, for example a URL, if you want to use a different audience than the client ID." That last sentence is the fix: create the mapper with your canonical MCP URI as the custom audience.
  4. 4 Then decide how it is requested. Attach the mapper's client scope as **Optional** and the audience appears only when the client sends scope=<that-scope>; attach it as **Default**, or put the mapper on the client's dedicated scope, and Keycloak will "always apply the audience for all the authentication request" — which is what you want, because an MCP client sends resource, not a Keycloak-specific scope value.
  5. 5 Keycloak's own setup checklist has two items, and the second is your server: "Ensure that services are configured to check audience on the access token sent to them. Ensure that access tokens issued by Keycloak contain all necessary audiences." Fixing only the realm side leaves the error in place if your verifier is comparing against the wrong string.
Keycloak: Audience Support

Amazon Cognito

If the issuer is an Amazon Cognito user pool and the token has no aud claim at all

  1. 1 A Cognito access token has no audience by default, and AWS documents that as the normal case. In the access token payload reference, aud is "the URL of the API that the access token is intended to authorize for. Present only if your application requested a resource binding from your authorization server." The claim that is always there is client_id, "the user pool app client that authenticated your user" — which is not an audience and is not what your MCP server should be comparing against.
  2. 2 Resource binding is Cognito's name for the thing the MCP spec requires: "With resource binding, also referred to as resource indicators, you can request API-specific grants from your user pool authorization server. Resource binding is an OAuth 2.0 extension defined in RFC 8707." When a client uses it, "Amazon Cognito sets the requested URI as the audience in the aud claim of the access token."
  3. 3 Turn it on the way AWS documents it: configure a resource server whose identifier is URL-formatted — "if the requested resource is a user pool resource server, the resource server identifier must be in a URL format" — then let the client include &resource=https://api.example.com/mcp on the /oauth2/authorize request. "You can request one resource per authentication request", which suits one MCP route per token.
  4. 4 Two limits decide whether this is available to you at all, and both are stated in the docs. "This feature is exclusive to managed login authentication with your user pool OAuth 2.0 authorization server. You can request resource binding in implicit and authorization-code grants from the Authorize endpoint… It is not currently available in SDK authentication models." And for machine callers: "You can only bind access tokens to resources for users. You can't request a resource binding with client-credentials M2M grants." An MCP server fronted by a client-credentials agent therefore has no aud to check on Cognito, and has to bind identity some other way.
  5. 5 Refreshes are safe once the first token is right: "token-refresh grants from the Token endpoint carry over the aud claim from the original request." So an audience that appears on login and vanishes an hour later is a different bug than this one.
text
https://<your-domain>.auth.us-east-1.amazoncognito.com/oauth2/authorize
  ?response_type=code
  &client_id=1example23456789
  &redirect_uri=https%3A%2F%2Fclient.example.com%2Fcb
  &scope=openid+mcp
  &code_challenge_method=S256
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &resource=https%3A%2F%2Fapi.example.com%2Fmcp
Amazon Cognito: Resource binding

FastMCP

If the value your own server expects is the thing that is wrong — a FastMCP JWTVerifier, or any verifier you configured by hand

  1. 1 audience on JWTVerifier is a plain string comparison against the token's claim, and FastMCP describes what it buys you: "The issuer parameter ensures tokens come from your trusted authentication system, while audience validation prevents tokens intended for other services from being accepted by your MCP server", and "only tokens with the correct issuer and audience claims will be accepted." Set it to the string your provider mints, which the curl above already told you.
  2. 2 Notice that FastMCP's own documented example uses audience= "mcp-production-api" — an opaque label, not a URI. That is a perfectly good audience for an internal service and a poor one for an MCP server, because the spec requires clients to send an absolute canonical URI as resource. If you keep a label, keep it deliberately, and know that no client-side resource value will ever produce it.
  3. 3 Omitting audience does not fail closed, it fails open: the parameter is simply absent from the comparison, and any token your issuer signed is accepted — including one minted for a sibling MCP route. That is the cross-service replay the spec's **MUST** exists to stop, so treat a missing audience as the more serious bug than the error you came here for.
  4. 4 Check which verifier you are actually running before you look for the parameter. FastMCP's documented options for IntrospectionTokenVerifier are the introspection URL, client credentials, required_scopes, client_auth_method and http_client — no audience among them — so an opaque-token deployment needs the audience asserted from the introspection response by other means. And DebugTokenVerifier "by default… accepts any non-empty token as valid", which is documented as development-only.
  5. 5 The same reasoning applies one layer down in the reference SDKs, where there is no audience setting to find: in the TypeScript SDK "verifyAccessToken is the one function you supply", and in the Python SDK the audience check lives in the TokenVerifier you write. The SDK's own example verifier does it with check_resource_allowed and logs Token resource validation failed. Expected: <resource> — which is this page's error under a different name.
python
from fastmcp import FastMCP
from fastmcp.server.auth.providers.jwt import JWTVerifier

verifier = JWTVerifier(
    jwks_uri="https://auth.example.com/.well-known/jwks.json",
    issuer="https://auth.example.com",
    # The aud your provider actually mints — copy it from the decoded
    # token, not from the URL you wish it were. Omit this and every token
    # this issuer signs is accepted, including tokens for other routes.
    audience="https://api.example.com/mcp",
)

mcp = FastMCP(name="Protected API", auth=verifier)
FastMCP: Token Verification

Zuplo MCP Gateway

If you would rather not negotiate an audience shape with each identity provider one MCP route at a time

  1. 1 For its MCP routes the gateway is both the OAuth 2.1 resource server and the authorization server, so it mints the token whose audience it later checks. Per the auth overview, "every gateway-issued access token is bound to" two things: "the canonical resource URI of the MCP route the user authorized for, derived from the request origin and the route path", and "the operationId of the route". There is no provider-specific audience shape to reconcile, because the provider is not the one issuing the token the MCP client presents.
  2. 2 The binding is enforced per route, not per project: "the gateway rejects a token presented at a different route or a different canonical resource. A token issued for /mcp/linear-v1 cannot be reused on /mcp/stripe-v1." That is the cross-route replay this error is supposed to prevent, made structural — the MCP OAuth policy "validates the gateway-issued bearer token, asserts audience binding and scope".
  3. 3 Your identity provider stays where it is. mcp-oauth-inbound delegates browser login to an OIDC provider you configure, and the gateway issues its own token afterwards — so whether Entra mints a GUID or Keycloak mints a client ID stops being something every MCP client has to agree with.
  4. 4 Two things to know before relying on it, both documented. The gateway's tokens are opaque, so there is no aud claim for you to decode in the curl above — the binding is checked at the gateway, not readable from the token. And the canonical resource URI "is constructed from the incoming request origin", honouring Host then X-Forwarded-Host: front it with a proxy that rewrites those and it will bind tokens to an origin the client never dialed, which is this same error one layer along.
Zuplo MCP Gateway: authorization

Something else

Whatever the provider is, this error has exactly two levers, and the curl above tells you which one to pull. Change what the authorization server puts in aud, by registering your canonical MCP URI as the resource identifier and making sure the parameter that selects it — resource, or a vendor-specific audience — actually reaches the token endpoint. Or change what your server expects, to the identifier the provider does mint. Prefer the first: the 2026-07-28 spec requires clients to send the canonical MCP URI as resource on every authorization and token request, so a provider that honours it needs no per-client special-casing. Take the second when the provider cannot be moved — and then keep one distinct audience per MCP route, so the check still does the job it exists for. What is not a lever is switching the comparison off. Both the spec and RFC 9068 make audience validation a **MUST**, and a server that checks only the issuer accepts every token its identity provider ever signed, including the one minted for the route you did not want this caller to reach.

Why it happens

The 2026-07-28 spec puts this check on you and gives you no discretion: "MCP servers **MUST** validate that access tokens were issued specifically for them as the intended audience, according to RFC 8707 Section 2", and "MCP servers **MUST** only accept tokens specifically intended for themselves and **MUST** reject tokens that do not include them in the audience claim or otherwise verify that they are the intended recipient of the token". RFC 9068 says the same thing for a JWT access token and names the wire error: the resource server "MUST validate that the aud claim contains a resource indicator value corresponding to an identifier the resource server expects for itself", and on failure the response "MUST include the error code invalid_token". So the rejection is correct behaviour. What is usually wrong is the value being compared. The spec tells clients what to ask for — the resource parameter "**MUST** be included in both authorization requests and token requests", "**MUST** use the canonical URI of the MCP server", and clients "**MUST** send this parameter regardless of whether authorization servers support it" — and that last clause is where this error lives. A client can send a perfect resource=https://api.example.com/mcp to an authorization server that ignores it, and get back a token whose aud is a GUID, a client ID, an API identifier you picked years ago, or nothing at all. Three other shapes produce the same string. The two values differ only by a trailing slash: an open MCP Python SDK issue traces exactly that to Pydantic's AnyHttpUrl appending one to a bare-domain resource, which then no longer matches the audience registered at the provider. The token is for a sibling route — the same issuer signs both, so only the audience check separates a public MCP route from an internal one. Or the server's expected audience is simply a leftover: a placeholder, a staging URL, or a value copied from a tutorial.

The exchange

Each step in the exchange, in order.

  • MCP client
  • Authorization server
  • MCP server resource server
  1. The client did what the spec requires — resource parameter, canonical URI, on both the authorization and the token request. Nothing obliges the authorization server to honour it, and this one did not.

    MCP client to Authorization server

    POST /token resource=https://api.example.com/mcp
  2. Authorization server to MCP client

    200 access_token aud: "api://11112222-bbbb-3333"
  3. Everything about the token is valid except who it is for. RFC 9068 requires the rejection and requires the error code invalid_token, so a conformant server has no option to let it through.

    MCP client to MCP server

    POST /mcp Authorization: Bearer eyJhbGciOi…
  4. Inside MCP server

    compare aud to own resource id https://api.example.com/mcp
  5. MCP server to MCP client

    401 Unauthorized Bearer error="invalid_token"

    Flow stops here

The MCP client asks the authorization server for a token, sending the RFC 8707 resource parameter naming the MCP server. The authorization server ignores that parameter and mints a token whose aud claim is its own API identifier instead. The client presents the token, the MCP server compares the aud claim against the resource identifier it publishes, they do not match, and the request is refused with 401 invalid_token.
Zuplo

What lands on you next

One claim, two strings, and an afternoon spent proving they were different. The token that finally gets in raises a different set of questions.

The audience is right. May this user call this particular tool?

require-user-claims-inbound

A correct audience says the token is for us, not that its subject is entitled. Required claims are asserted per request, per user, before the call reaches your server.

Which tools does this route expose in the first place?

mcp-capability-filter-inbound

A static per-route allow-list, with no per-user axis: capabilities outside it are hidden from tools/list and blocked with MethodNotFound.

The caller is authenticated. What does the upstream API receive?

mcp-token-exchange-inbound

Not this token: the spec forbids passthrough with a **MUST NOT**. The inbound credential is dropped and an independent upstream one is resolved per user.

We rejected a token. Where does that show up an hour later?

mcp_request_rejected

A rejected MCP request emits its own analytics event, so a wave of audience failures is something you can watch rather than something a user has to report.

All of these attach to one MCP route's policies.inbound — the same policy engine on the way in and on the way out.

Stop debugging auth on every MCP server

A gateway in front of your MCP servers handles discovery, audience binding, and token validation once — for every server and every client.