Chapter 3 of 8 · 15 min read
Token audience binding
On this page
An access token is a bearer credential, meaning it works for whoever holds it. The audience claim, written aud, is the one field that narrows that: it names the resource the issuer intended the token to be presented to, and a resource server compares it against its own canonical identifier before doing anything else. Two services behind the same identity provider therefore refuse each other's tokens, even though both trust the same signing key and both would recognise the user.
That check is easy to meet and easy to miss, because nothing about a token's freshness, signature or scopes reveals which recipient it names. The failure it produces is the most confusing in this topic — a credential that works against one endpoint and is refused by another in the same minute, on the same domain, for the same user — and it is worth causing on purpose before meeting it in an incident.
The same token, accepted and refused
Run the authorization flow from chapter 1, where a client discovers an authorization server and completes an authorization-code grant, against Northwind, leave resource off the authorization request, and take the access token you get back. Present it to Northwind's REST API:
curl -sS -i https://api.northwind-analytics.com/v2/reports?limit=1 \
-H "Authorization: Bearer $TOKEN"HTTP/1.1 200 OK
Content-Type: application/json
{"data":[{"id":"rpt_41c9","name":"Q1 pipeline","updated_at":"2026-04-02T09:14:00Z"}]}Present the same token, in the same minute, to Northwind's MCP server:
curl -sS -i -X POST https://mcp.northwind-analytics.com/mcp \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="audience mismatch", resource_metadata="https://mcp.northwind-analytics.com/.well-known/oauth-protected-resource/mcp"Same issuer, same user, same client id, same scopes, nine hundred seconds of remaining life, and one of the two accepts it. Every instinct trained on expiry and signatures points at the wrong things — refresh it, rotate the client secret, re-run the flow — and none of those changes the outcome, because the token is not stale and it is not forged. It was minted for a different recipient.
Decode it before doing anything else
The access token here is a JWT, a JSON Web Token: three dot-separated segments — a header, a payload of claims (the statements the issuer is making about the token, such as who it is for and when it expires), and a signature over both — each encoded in base64url, which is ordinary base64 with the characters that would break a URL swapped out. Reading the middle segment therefore costs one command:
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq .{
"iss": "https://auth.northwind-analytics.com",
"sub": "user_8812",
"aud": "https://api.northwind-analytics.com",
"client_id": "c_9f31ab72e0c4",
"scope": "reports:read accounts:read",
"iat": 1775695500,
"exp": 1775696400,
"jti": "t_a19f0c3e"
}sub is the subject, meaning the user this token was issued for, and aud is the audience. Here aud reads https://api.northwind-analytics.com, which is the REST API's identifier and not the MCP server's. Northwind's authorization server had no instruction about which resource this token was for, so it fell back to a default — usually the first or only audience configured for the client, or a tenant-wide default API — and that default was the REST API. The MCP server compares aud against its own resource identifier, https://mcp.northwind-analytics.com/mcp, finds no match, and refuses. It is behaving correctly, and it is the only party in the exchange that is.
Why a default exists at all is worth a sentence, because it is what makes this a configuration outcome rather than a bug in anybody's code. An authorization server has to put something in aud, since a token carrying no audience leaves every resource server to decide on its own what that silence means. When the request names no target, the only information available is what the deployment has registered — the client's configured audience, or a tenant-wide default — so that is what it uses. Nothing has gone wrong from its point of view: it was asked for a token, it was told nothing about where the token was going, and it issued the token it issues in that case. The client's omission is the whole of the fault, and the omission stays invisible because the resulting token works everywhere the team happened to try it.
The claim is doing the work a payee line does on a cheque. A bearer instrument is redeemable by whoever holds it, which is convenient and is why bearer tokens are simple to implement; a named payee makes the same piece of paper worthless to anyone else. aud is the payee line, and the reason it is worth the extra parameter is that tokens leak far more readily than cheques — into logs, error reports, browser histories, crash dumps, and the context window of a language model that was asked to summarise an HTTP trace.
That is an analogy, and it is worth marking where it stops before the picture does damage. A cheque's payee line is checked by a bank that also knows the drawer, holds the account, and can be instructed to stop the instrument after it has been written — none of which has a counterpart here. Nobody stops a JWT. Once issued it is valid until it expires, because the resource server verifying it usually has no channel back to the issuer during the check. The payee line is also checked against the identity of the person presenting the cheque, whereas aud is checked against the identity of the service receiving the token and says nothing whatever about who is holding it. So the comparison is right about one thing, which is that naming a recipient makes an instrument worthless elsewhere, and misleading about two: it suggests a recall mechanism that does not exist, and it suggests the check learns something about the bearer, which it does not.
The repair belongs at issuance
The tempting fix, when the failing party is a server you also operate, is to relax the check: accept an array of audiences, or skip the comparison for tokens from a trusted issuer. That converts a correct refusal into a silent acceptance and removes the only thing making the boundary real. The repair is one parameter on the authorization request and the token request, from RFC 8707:
https://auth.northwind-analytics.com/oauth2/authorize
?response_type=code
&client_id=c_9f31ab72e0c4
&redirect_uri=http%3A%2F%2F127.0.0.1%3A33418%2Fcallback
&scope=reports%3Aread+accounts%3Aread
&state=v7Qm2s0Lk9dRr1Xw
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
+ &resource=https%3A%2F%2Fmcp.northwind-analytics.com%2FmcpThe value is the canonical resource identifier the server published in its protected resource metadata, copied verbatim — an absolute URI, no fragment, and compared later by exact string match, which is why "copied verbatim" is not a stylistic preference. resource goes on the authorization request so the authorization server can involve it in the consent decision, and again on the token request so the code is redeemed for that specific target. Repeat the parameter to request a token valid at more than one resource, though for MCP the useful default is one resource per token.
"Copied verbatim" needs saying because the comparison at the far end is a string equality test, and URIs offer many ways to say the same thing that are not the same string. HTTPS://MCP.Northwind-Analytics.com/mcp addresses the identical server; RFC 8707 requires the scheme and host to be normalised to lower case, so that one is repairable. https://mcp.northwind-analytics.com:443/mcp also addresses the identical server, and the explicit default port makes it a different string that a strict resource server will reject. A fragment is forbidden outright. Query components are permitted but almost never wanted, since a resource identifier is meant to name a service rather than a request. The safe procedure is mechanical: take the resource value out of the protected resource metadata document, store it as an opaque string, and send that string back. Constructing the identifier yourself from the URL in the config file is where the discrepancies come from, because the URL a human typed and the identifier the server publishes are only accidentally equal.
The rule underneath "copy it verbatim" is that the safe operation on an identifier is transport rather than construction. Every discrepancy in that list arises from somebody building the string out of parts — a host from configuration, a path from a route table, a scheme from a constant, a port added by a URL library that thought it was being helpful — and each part is an independent opportunity to differ from what the far end registered. Copying has no parts, so it offers no opportunities. That is why this is worth stating as a mechanical procedure rather than as an instruction to be careful: care scales badly across a fleet of clients written by different people at different times, and a rule that removes the opportunity scales perfectly.
Run the flow again with the parameter present and the payload changes in one place:
{
"iss": "https://auth.northwind-analytics.com",
"sub": "user_8812",
"aud": "https://mcp.northwind-analytics.com/mcp",
"scope": "reports:read accounts:read",
"exp": 1775696400
}That token now succeeds against the MCP server and fails against api.northwind-analytics.com with a symmetrical invalid_token, which is the state you want: two credentials, two audiences, neither usable in the other's place. The MCP specification makes both halves normative — clients MUST send resource on authorization and token requests, servers MUST validate that tokens were issued for them — and it is one of the few places the document uses that language.
Two failure modes are worth recognising while wiring this up. If the authorization server answers {"error":"invalid_target"}, it does not know that resource identifier: either the value does not match what the server has registered as an audience, or the deployment simply has no resource registered for the MCP server yet, and the fix is a configuration change in the IdP rather than in your client. If the authorization server ignores resource entirely and keeps issuing the same default audience, it does not implement RFC 8707, and no amount of client-side care will produce a bound token — the honest options are a different authorization server, or a proxy in front of it that performs a token exchange (RFC 8693) to narrow the audience.
Every authorization server spells this differently
RFC 8707 was published in 2020 to standardise something several vendors had already invented independently, each with its own parameter name, because the problem it solves — one identity provider, many APIs, tokens that must not be interchangeable — arrived long before MCP did. The standard parameter has been winning since, and MCP's normative requirement is accelerating that, but a client connecting to arbitrary servers still meets the older spellings. Knowing which one a deployment speaks turns an unexplained invalid_token into a ten-minute configuration change.
| Authorization server | How you name the target | What lands in aud | Notes |
|---|---|---|---|
| Okta (custom auth server) | resource per RFC 8707, or the audience configured on the auth server | The auth server's configured audience, or the requested resource | One custom authorization server per audience is the common pattern |
| Auth0 | audience=<API identifier> | The API identifier string registered in the dashboard | The API must exist before the request; unknown values return invalid_target |
| Microsoft Entra ID (v2) | Scopes prefixed with the resource: api://mcp-northwind/reports.read | The application ID URI of the target app registration | The v1 resource parameter is deprecated but still honoured on some tenants |
| Keycloak | audience via a token-exchange or an audience-mapper on the client scope | Whatever the mapper writes | Silent default: no mapper means no aud, and every resource server sees a token with nothing to check |
| Amazon Cognito | Resource-server scopes: https://mcp.example.com/reports.read | The client id, not the resource | Audience separation has to be inferred from scope prefixes, which is weaker |
The Keycloak row is the one that catches teams out, because a token with no aud claim at all is not obviously broken — it verifies, it has a subject, it has scopes — and a resource server that treats a missing claim as "not applicable" rather than as a failure will accept it everywhere. The correct handling of a missing aud is refusal, on the same grounds as a mismatched one: the token does not say it was issued for you, so you have no basis for the belief that it was.
The belief that makes the permissive reading feel safe is that a missing field means the constraint does not apply, which is how optional fields behave in nearly every schema anyone has worked with. This field is not optional in that sense. It is the only statement the issuer makes about where the token may be spent, so its absence is not "unconstrained on purpose", it is "the issuer said nothing". A validator treating that silence as permission accepts every token in the tenant at every service in the tenant. The general form is worth carrying beyond this claim: a check that passes when its input is missing is not a check, it is a check with a hole shaped exactly like the absent case — and absent cases are the easiest thing in the world for a misconfiguration to produce, since producing one takes no action at all.
The count in that case is worth adding up rather than accepting, because the whole argument is a difference between two numbers. Two MCP servers, a REST API, an internal reporting service and a data-export job is five named systems, and nine others brings the estate to fourteen. A token with no audience, or with a tenant-wide one, is presentable at all fourteen. A token bound to the leaking MCP server is presentable at one. The difference is thirteen services a leaked credential reaches that had no part in the incident, and the same thirteen turn up again as audit trails somebody has to read before the scope of the leak can be stated. None of that arithmetic depends on which services they are. It depends only on how many systems trust the issuer, which is a number that grows quietly as an estate consolidates onto one identity provider — and it grows in the direction that makes an unbound token worse. The same top row arrives again in chapter 5, where a token is not stolen at all but handed to an upstream service deliberately, producing that outcome with none of the excitement of a breach.
Audience is not scope, and not proof of possession
Conflating audience with scope produces both an over-permissive and an under-permissive system. aud answers which service may accept this token; scope answers what may be done there. A token with aud correct and scope: reports:read is refused by the write tool on the very server it was minted for, and that refusal is a 403 rather than a 401 — a distinction chapter 8, on telling those two statuses apart, leans on hard. Setting scopes carefully while leaving audience defaulted produces a token that is narrow in what it can do and usable in far too many places; setting audience carefully while granting a wildcard scope produces the reverse.
It is also not authentication, and not proof of possession. A bearer token with a correct aud is still a bearer token: anyone who obtains it can use it at that one resource until it expires. Binding it to a key held by the client — the sender-constrained tokens of RFC 8705 (mutual-TLS) or RFC 9449 (DPoP) — is the control that survives theft outright, and it is where this area is heading for high-value deployments, at the cost of key management in every client. Short lifetimes are the cheap approximation most deployments settle for: a token that lives 900 seconds is worth stealing for 900 seconds.
Nor does it help when two things share one identifier. If an organisation registers a single audience, https://internal.acme.com, and lets its MCP server, its reporting service and its admin API all validate against that string, every token is valid at all three and the check passes at every hop while bounding nothing. The identifier has to be as specific as the trust boundary you want, which for MCP means the server's own canonical URL, path included.
Finally, none of this presumes JWTs. Opaque tokens carry no readable claims, and the resource server validates them by calling the authorization server's introspection endpoint (RFC 7662), which returns aud and scope in its response and requires the same comparison against the server's own identifier. Introspection costs a network round trip per request, typically 5 to 20 ms inside one datacentre, which is the trade for immediate revocation — a revoked opaque token stops working on the next call, while a revoked JWT keeps working until it expires unless the server maintains a revocation list.
Audience binding is therefore a containment control rather than a prevention one, and stating that plainly is what keeps it from being argued away. Nothing about aud stops a token being written to a log, read by someone who should not have it, or replayed at the service it was issued for. What it does is make a leak worth one service instead of an estate, and it costs one parameter at issuance and one string comparison at validation — which is why the relaxation that unblocks a demo on Thursday is expensive in a way that will not be visible until something leaks.
The same containment logic is why the audience check is not a formality your own servers may skip among themselves. Everything so far has been the client's half of the arrangement: what to send, what to check on the way back, and what a well-behaved server will refuse. The next chapter turns it around, because serving the 401 that names your authorization server, publishing the metadata behind it, and running the audience comparison that made this chapter's token fail are all things your own MCP server has to do, in a specific order, before any tool code executes.
MCP auth — read the current version at https://daviddominguez.dev/learn/en/mcp-auth/token-audience-binding