A wrong-audience MCP OAuth 401 on a shared issuer needs evidence from three places, not just connection order

Two MCP servers share an OAuth issuer, sign-in works, and the second server answers 401 with a wrong audience. Connection order narrows the hypotheses. Correlated records from the issuer, the edge and the server are what locate the fault.

Written by Worksi's AI marketing agents and checked by a separate AI fact-checking agent.

How this blog is made
A long chain on a workbench, half closed off by a clamp, with threads from three spools tied around the same cracked orange link.
Illustration made with AI
In this article
  1. A reported case, and what settled it
  2. "Issued for this server" does not mean "contains this server's URL"
  3. The synthetic fixture
  4. The runs, with the two state stores changed separately
  5. What the patterns suggest, and what is still needed
  6. The evidence table
  7. The causes, and what each needs before you blame it
  8. Fix the component, then run both orders again

Two MCP servers trust one shared OAuth issuer. You connect a client to server A, sign in, and the tools load. You connect the same client to server B, and B answers 401 with an audience mismatch. The sign-in worked, so the usual suspects are B's configuration or the client's token cache. Either can be right, and so can the issuer or a proxy. Neither the 401 nor the fact that it depends on connection order tells you which.

This article proposes a diagnostic method, not a guaranteed attribution. Runs in both connection orders, with separate controls for client state and issuer session state, narrow the hypotheses. Correlated records from three places, the issuer, the edge in front of B and B's verifier, can then identify the component when they agree. When one of them is missing, the honest answer is a shorter list of suspects. We have not executed this fixture for the article: every value below is synthetic. No audience check is weakened at any step.

The remote MCP 401 checklist on mcptest.io already covers discovery, the resource parameter, audience, scopes, expiry and proxies that strip or alter headers. This piece adds the order-reversal controls and the evidence needed to move from "consistent with" to "shown by".

A reported case, and what settled it#

In August 2026 a user filed issue #1969 against the GitHub Copilot plugin for IntelliJ, version 1.14.2. Two MCP servers with distinct protected resources shared one issuer. After signing in for A, B failed with 401, and B's resource server logged an audience mismatch: it received a token for client client-a with A's audience. The reporter saw the same in the reverse order. The client log showed sessions being looked up by scope (openid) alone. A maintainer identified session selection that matched an existing session "without adequately distinguishing the protected resource", said the fix is available from 1.17.0, and closed the issue on 21 October 2026.

That is one client version, fixed. It is no evidence about current clients or your 401. What makes it useful is the evidence: the server named the audience it received and expected, and the client log showed the key it used to select a session. Without that client log, the server's record alone would not have located the defect. The same thread has a commenter reporting the wrong client ID with only one server configured, so a client defect can also appear in a single-server baseline.

"Issued for this server" does not mean "contains this server's URL"#

The MCP authorization tutorial for revision 2026-07-28 describes the protected resource metadata (PRM) resource as the exact URL clients connect to. The client sends it as resource in the authorization request and again at the token endpoint, and the server checks that the token was issued for it. The tutorial notes that some authorization server products always use their own audience identifier. Then the server compares the audience with that identifier, which must belong to that one MCP server, so the tutorial says not to share one application registration between servers. RFC 8707 allows the authorization server to use the resource value as the audience or map it to another identifier.

So the check is not "does aud contain B's URL". It is "does the authenticated audience match the identifier B expects, and is that identifier B's alone".

The synthetic fixture#

  • Issuer https://idp.example.test, run in a test tenant with test accounts and test credentials only.
  • Server A at https://mcp-a.example.test/mcp, expected audience aud-mcp-a. Server B at https://mcp-b.example.test/mcp, expected audience aud-mcp-b. Each PRM resource is its URL, and the issuer maps each resource to its own audience.
  • Both servers verify signature, issuer, audience and expiry, with no exceptions, and expose only tools/list or a read-only echo tool.
  • An edge you run terminates TLS in front of each server, with no other intermediary between the client and that edge. That makes the edge record a record of what left the client.
  • One account, one client build, one scope (mcp:tools), one deployment per server.

You never send a token to the other server on purpose. The client decides what it sends. You record what arrives.

The runs, with the two state stores changed separately#

Two stores can carry state from the first connection to the second: the client's profile (registrations, sessions, tokens) and the browser session at the issuer. A "fresh profile" that also signs out of the issuer changes both at once, so it cannot tell them apart. The controls below change one at a time.

Run Steps What it varies
1. A only New client profile, signed-out issuer session, connect A, tools/list Nothing carried over
2. B only The same for B Nothing carried over
3. A then B New profile, connect A, then B in the same profile and browser State left by A
4. B then A The reverse State left by B
5a. Client control After a failing run, a new client profile, issuer browser session kept signed in, connect only the failing server Client profile only
5b. Issuer control Keep the failing run's client profile, sign out of the issuer or clear its cookies only, reconnect the failing server Issuer session only

Run 5b is not possible with every client: some keep tokens you cannot preserve while forcing a new sign-in. Where it is not, mark it not run. Before and after each control, record at the issuer which client registrations exist and which grants or consents the account holds, because a new profile may register a new client and change both. Repeat each run at least twice.

What the patterns suggest, and what is still needed#

Every row is a hypothesis. Order-dependence shows that state is involved, not whose state it is.

Observation Hypotheses still open Evidence that separates them
Run 1 or 2 fails That server's discovery, issuer mapping, verifier or proxy, or a client defect visible with one server The evidence table for the failing run
Baselines pass; the second server fails in both orders Client token or session selection, issuer reuse of a grant or session, proxy state Issuer issuance records matched to the token the edge received
Only one order fails Something asymmetric; one hypothesis is a scope-keyed client lookup with different scopes per server The client's lookup key if it logs one, and each server's requested scopes
5a passes, 5b fails A lead toward client-profile state The same token evidence; a changed registration can also explain it
5a fails, 5b passes A lead toward the issuer session or grant Issuer records for the second authorization
Both controls fail Server, proxy or issuer configuration, or state neither control reset Compare with the baselines and the registration records
Nothing fails Not reproduced under these conditions How your real setup differs: account, scopes, client version, proxy

The evidence table#

One row per connection. Each column comes from a different place, and a disagreement between neighbors is where to look.

Column Source A correct row for B shows
Endpoint and discovered PRM resource Client debug log, if it has one B's URL in both
resource in authorization and token requests Issuer logs B's URL, and a token request for this connection
Token issued Issuer: fingerprint, resource, audience, client ID A B-audience token for this connection
Token received Edge and application fingerprints, logged separately The fingerprint the issuer issued for B
Audience received B's verifier, authenticated claims only; opaque tokens: authorized introspection aud-mcp-b, or "unavailable"
Validation reason B's verifier log Accepted, or one reason

Not every row can be completed. A hosted issuer may not log token fingerprints; if your tokens carry a jti, that can correlate instead. Where the issuance column is empty, issuance and client selection stay unresolved.

Fingerprints. Never log the token, refresh token, authorization code or PKCE verifier, and never paste a token into a chat, ticket or decoding website. Our suggestion is a keyed HMAC-SHA-256 of the token, with a key held only by the test harness, logged in full to access-controlled logs at the issuer, edge and application. A full 256-bit value makes an accidental match negligible. A short prefix does not: eight hex characters have about 4.3 billion values, so a match is a lead, not proof of equality.

Authenticated claims, not decoded ones. A decoded JWT shows claims nobody verified. In the tutorial's jwtVerify path an audience mismatch throws, so no successful result carries the audience. jose documents that JWTClaimValidationFailed carries the parsed payload, that with jwtVerify token authentication precedes claim validation, and that validation of other claims may be incomplete. So you can log the received aud from that error while still refusing the token, and mark the other claims unchecked.

For opaque tokens, RFC 7662 makes aud optional in an introspection response. If the protected resource is not allowed to introspect a token, the authorization server must answer active: false and should not say why. B may never learn A's audience. Record "audience unavailable" and use the issuer's own diagnostics.

Reasons, not only statuses. In the tutorial's TypeScript example, JOSE failures that mean the token is invalid, except the listed key-fetch outage codes, all become the same invalid_token answer, while outages and other exceptions surface as server errors. That is right for the client and opaque to you. Record the reason before converting it:

TypeScript
} catch (error) {
  if (error instanceof errors.JOSEError && !JWKS_OUTAGE.includes(error.code)) {
    // The signature was verified before claim checks ran; the token is still refused
    const audFailure =
      error instanceof errors.JWTClaimValidationFailed && error.claim === "aud";
    console.warn(JSON.stringify({
      event: "token_rejected",
      server: "mcp-b",
      reason: error.code,
      claim: error instanceof errors.JWTClaimValidationFailed ? error.claim : undefined,
      received_aud: audFailure ? error.payload.aud : undefined, // other claims unchecked
    }));
    throw new OAuthError(OAuthErrorCode.InvalidToken, "Invalid access token");
  }
  console.error("Could not verify the token:", error);
  throw error;
}

A complete synthetic row, fingerprints shortened here for width:

run=3 step=2 endpoint=https://mcp-b.example.test/mcp prm_resource=https://mcp-b.example.test/mcp
issuer: authz_request=<none for this connection> token_request=<none for this connection>
issuer: issued at run=3 step=1 resource=https://mcp-a.example.test/mcp aud=aud-mcp-a fp=hmac:9b1e…
edge(B): header=present fp=hmac:9b1e… (equal in full)
app(B):  header=present fp=hmac:9b1e… (equal in full) reason=audience_mismatch received_aud=aud-mcp-a expected=aud-mcp-b

With no intermediary between the client and B's edge, this row shows that the token B refused left the client, that the issuer issued it for A's resource, and that it reached the application unchanged. That supports client-side selection. Remove the issuer and edge lines and the same 401 says only that B observed a token consistent with A's: selection or substitution would remain to be located.

The causes, and what each needs before you blame it#

  • Wrong or missing resource. The issuer's records for B's connection show A's URL or none. RFC 8707 lets an issuer apply a default resource or refuse with invalid_target. Fix the client or its configuration.
  • Issuer returned the wrong audience. Only when the issuer's record for the correlated B request shows B's resource requested, an A or shared audience issued, and the same fingerprint the edge received. A shared application registration is one possible cause, not a demonstrated one.
  • Client sent the wrong token. The edge fingerprint matches a token issued for A's resource. Check whether the issuer issued a correct B token the client did not send, and rule out legitimate reuse of an earlier B token. An absent token exchange or a short-prefix match alone is a lead.
  • B expects the wrong audience. The authenticated audience is aud-mcp-b, the issuer intended aud-mcp-b, and B still refuses on audience. Usually visible in run 2.
  • A proxy in the way. The edge and application fingerprints differ, or the header is present at one and absent at the other. A substituted valid token can fail on audience, signature, issuer or expiry, not only as a missing token.
  • Expired or invalid token. The reason names expiry, signature or issuer. Expiry follows time, not order: repeat the failing run straight after sign-in.
  • 403 insufficient_scope. In the tutorial's server, a missing or invalid token gets 401, and a valid token lacking a scope gets 403 with an insufficient_scope challenge. There, a 403 means the token passed validation.

A successful sign-in, a decoded token or one 200 from A shows none of this. Each shows one step, not which token B received or why.

Fix the component, then run both orders again#

Workarounds that make the 401 disappear remove different protections. Adding A's audience to B's accepted list, or one registration and audience for both servers, lets a token issued for one server work at the other: the redirect RFC 8707's audience restriction exists to prevent. Turning off signature validation removes proof that the token is authentic. Turning off issuer validation lets tokens from another issuer through. Keep all three.

Fix the component the evidence supports. Where it supports only a shorter list, gather the missing column before changing anything. Then repeat every run, both orders and both controls. A fix tested in one order is half-tested.

Tool discovery and invocation after sign-in are a separate layer; our MCP authorization test matrix covers it. If you route MCP traffic through Worksi, its MCP gateway at /mcp/servers/{slug} filters tools/list, checks each tools/call and records every call as a span, and Worksi's own MCP server takes an API key or a personal access token. This article does not test Worksi with the OAuth flow above. Read how the Worksi MCP integration works.

Sources

  1. Understanding Authorization in MCP (tutorial, revision 2026-07-28)
  2. RFC 8707: Resource Indicators for OAuth 2.0
  3. RFC 7662: OAuth 2.0 Token Introspection
  4. jose: JWTClaimValidationFailed
  5. Wrong OAuth token/session reused across multiple MCP servers sharing one issuer, issue #1969
  6. Remote MCP server returns 401 Unauthorized: the cross-client checklist

How this blog is made

Worksi's AI marketing agents write the articles here, and a separate AI fact-checking agent checks each one before it is published. The sources an article cites are listed with it. Its illustration was made with AI. If you find a mistake, write to info@worksi.ai.