ai-ml

MCP Token Audience Check: expectedResource Tested (2026)

October 6, 2026

MCP Token Audience Check: expectedResource Tested (2026)

TL;DR: MCP token audience validation is the check that an access token was issued for your MCP server and not for some other service. The TypeScript SDK added an option for it on October 2, 2026, and it is off until you set it.1

In my tests on SDK 1.32.1, a token issued for a different server got HTTP 200 until I set expectedResource, and HTTP 401 after.2 On @modelcontextprotocol/express 2.0.1, installed next to server 2.3.1, the option did nothing at all.2

MCP token audience validation: the short answer

A protected MCP server acts as an OAuth resource server. The spec says it must only accept tokens issued for it, so a token minted for another API cannot be replayed against it.3

In the TypeScript SDK, requireBearerAuth gets this check through expectedResource. You set it to the value your authorization server puts into tokens meant for your server, and your verifier must report that value back as AuthInfo.resource.4 In the SDK's types, both are URL objects.52

What you'll learn

  • What the MCP spec requires of a server about token audience
  • What changed in the TypeScript SDK on October 2, 2026
  • A small rig that tests ten token shapes against the guard
  • How expectedResource compares values, including slashes, fragments and case
  • Why a verifier that forgets resource locks every user out
  • Why the Express adapter version matters
  • A copy-paste setup and a checklist

What the MCP spec says about token audience

The 2026-07-28 authorization page says servers "MUST validate that access tokens were issued specifically for them as the intended audience," according to RFC 8707 Section 2.3 The MCP versioning page lists 2026-07-28 as the current revision.6

The security page repeats it as a rule about "Access Token Privilege Restriction": servers must reject tokens that do not include them in the audience claim, or otherwise verify they are the intended recipient.3

RFC 8707, published in February 2020, defines the resource request parameter that lets a client tell the authorization server which resource it wants a token for.7

The spec also forbids passthrough. If your MCP server calls an upstream API, the security page says it "MUST NOT pass through the token it received from the MCP client."3

What changed in the TypeScript SDK on October 2

The v1 line shipped @modelcontextprotocol/sdk 1.32.0 on October 2, 2026. The v2 line shipped @modelcontextprotocol/server 2.3.0 the same day.1

The 1.32.0 release notes list two new options, "both off unless you set them." One is maxToolInputElements. The other is expectedResource on requireBearerAuth, which "accepts only tokens issued for this server (the token's audience)." The 2.3.0 notes list the same two options as minor changes, and say that with expectedResource unset, nothing changes.1

The pull request that added it to v2 was merged on October 2, and it says the comparison runs before the scope and expiry checks.5 The bearerAuth.js file in the installed 1.32.1 package agrees: the audience check comes first.2

On October 5 the project shipped 1.32.1 and 2.3.1. The 2.3.1 notes add the same option to @modelcontextprotocol/server-legacy.1

Build a token audience test rig

I did not want to trust a changelog, so I built a fake authorization server and a guard. It has ten tokens, each with a different audience shape, including one with no aud claim at all.2

The verifier picks the aud entry on this server's host and returns it as resource. That mirrors the pattern in the SDK docs, which report "this server's entry" from aud and leave resource unset when there is none.4

// tokens.mjs: a fake authorization server. Opaque token -> claims (stands in for a JWT's `aud`).
export const SERVER_URL = new URL('https://mcp.example.com/mcp');
const exp = Math.floor(Date.now() / 1000) + 3600;

export const CLAIMS = {
  'tok-ours':      { aud: 'https://mcp.example.com/mcp', exp },
  'tok-other':     { aud: 'https://other.example.org/mcp', exp },   // issued for a different server
  'tok-no-aud':    { exp },                                          // no audience at all
  'tok-trailing':  { aud: 'https://mcp.example.com/mcp/', exp },     // one trailing slash
  'tok-fragment':  { aud: 'https://mcp.example.com/mcp#x', exp },    // fragment
  'tok-two-slash': { aud: 'https://mcp.example.com/mcp//', exp },    // two trailing slashes
  'tok-origin':    { aud: 'https://mcp.example.com', exp },          // origin only, no path
  'tok-http':      { aud: 'http://mcp.example.com/mcp', exp },       // wrong scheme
  'tok-upper':     { aud: 'HTTPS://MCP.EXAMPLE.COM/mcp', exp },      // uppercase scheme and host
  'tok-list':      { aud: ['https://other.example.org/mcp', 'https://mcp.example.com/mcp'], exp }, // array
};

// mode: 'url' reports `resource` as a URL object, which the SDK types require and its docs use.
// 'string' reports a plain string: only plain JavaScript allows that. 'none' reports no `resource`.
export function makeVerifier({ mode = 'string' } = {}) {
  return {
    async verifyAccessToken(token) {
      const c = CLAIMS[token];
      if (!c) throw new Error('unknown token');
      const auds = [c.aud ?? []].flat();
      const hit = auds.find(a => { try { return new URL(a).host === 'mcp.example.com'; } catch { return false; } });
      const info = { token, clientId: 'client-1', scopes: ['mcp'], expiresAt: c.exp };
      if (mode === 'string' && hit) info.resource = hit;
      if (mode === 'url' && hit) info.resource = new URL(hit);
      return info;
    },
  };
}

The second file starts two Express apps, one without the option and one with it. It sends every token to each, plus one request with no Authorization header, and prints the status codes.

// matrix.mjs: node matrix.mjs string | node matrix.mjs url | node matrix.mjs none | node matrix.mjs url origin
import express from 'express';
import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';
import { CLAIMS, SERVER_URL, makeVerifier } from './tokens.mjs';

const mode = process.argv[2] ?? 'string';
const expected = process.argv[3] === 'origin' ? new URL('https://mcp.example.com') : SERVER_URL;

async function statuses(expectedResource) {
  const app = express();
  const guard = requireBearerAuth({ verifier: makeVerifier({ mode }), requiredScopes: ['mcp'], expectedResource });
  app.post('/mcp', guard, (req, res) => res.json({ ok: true }));
  const server = await new Promise(r => { const s = app.listen(0, '127.0.0.1', () => r(s)); });
  const url = `http://127.0.0.1:${server.address().port}/mcp`;
  const out = {};
  for (const t of Object.keys(CLAIMS)) {
    out[t] = (await fetch(url, { method: 'POST', headers: { authorization: `Bearer ${t}` } })).status;
  }
  out['(no header)'] = (await fetch(url, { method: 'POST' })).status;
  server.close();
  return out;
}

const off = await statuses(undefined);
const on = await statuses(expected);
console.log('token'.padEnd(14), 'no option', 'expectedResource');
for (const t of Object.keys(off)) console.log(t.padEnd(14), String(off[t]).padEnd(9), on[t]);

Install and run it in an empty folder with "type": "module" in package.json:

npm i @modelcontextprotocol/sdk@1.32.1 express@5
node matrix.mjs string

Results: a token for another server gets HTTP 200 until you opt in

This is the output on SDK 1.32.1 with a verifier that returns resource as a string, which only plain JavaScript allows:2

token          no option expectedResource
tok-ours       200       200
tok-other      200       401
tok-no-aud     200       401
tok-trailing   200       200
tok-fragment   200       200
tok-two-slash  200       401
tok-origin     200       401
tok-http       200       401
tok-upper      200       401
tok-list       200       200
(no header)    401       401

Without the option, all ten tokens got 200, including tok-other and tok-no-aud. With it, six were rejected.2 In the SDK's own guard, MCP token audience validation does not happen until you set expectedResource.

The last row sends no Authorization header at all. It got 401 in both columns, so the guard still demands a token.2

Matrix of HTTP status codes for ten token shapes and one request with no Authorization header, across six configurations. SDK 1.31.0 with the option set and SDK 1.32.1 without it accept a token for another server and a token with no audience. Express adapter 2.0.1 does the same. SDK 1.32.1 and Express adapter 2.0.2 with the option set reject both. Figure 1: Status codes from my runs. Red cells are tokens for another server, or with no audience, that were accepted. Columns 1 to 3 used a verifier that returns resource as a string, columns 4 to 6 used a URL object.

requireBearerAuth expectedResource: what it accepts and rejects

The SDK compares expectedResource with the resource your verifier reports as strings, ignoring a fragment and one trailing slash.15 My results are consistent with that.

Here is what the ten tokens showed:

  • Exact match, one trailing slash, a fragment: accepted (3 of 3).
  • Array aud with our entry in it: accepted, because my verifier picked the right entry before the guard saw it.
  • Two trailing slashes: rejected. Only one slash is ignored.
  • Origin only (https://mcp.example.com): rejected, because it differs from https://mcp.example.com/mcp.
  • http:// instead of https://: rejected.

The origin-only case matters. The spec lists https://mcp.example.com as a valid canonical URI for a server.8 The option takes one value, so it must equal what your authorization server issues. With expectedResource set to new URL('https://mcp.example.com') (node matrix.mjs url origin), only tok-origin passed and tok-ours got 401.2

Why an uppercase audience fails with a string resource

The spec's canonical form is lowercase, but it says implementations "SHOULD accept uppercase scheme and host components for robustness and interoperability."8

The guard compares serialized strings. A string HTTPS://MCP.EXAMPLE.COM/mcp does not equal the lowercase expected value, so it was rejected in plain JavaScript.2

A URL object serializes in normalized lowercase, so the same audience passed. Run node matrix.mjs url to see tok-upper flip from 401 to 200.2

Typed code does not normally hit the string case. AuthInfo.resource is a URL, and the TypeScript compiler rejected a string resource in my check.2 Use new URL(...) when you fill it, as the docs example does.4

A verifier that forgets resource locks everyone out

If you set expectedResource and your verifier never sets AuthInfo.resource, the guard has nothing to compare. In my run, node matrix.mjs none rejected all ten tokens with 401, including tok-ours.2

That fails closed, which is the safe direction. The docs also state the other case: "When expectedResource is not set, AuthInfo.resource is not compared with anything."4

The SDK does not read the aud claim for you. Your verifier does that and reports the result.42

Express adapter 2.0.1 silently ignores expectedResource

In v2, the Express middleware lives in its own package, @modelcontextprotocol/express. The 2.3.0 release notes say 2.0.1 "does not pass the option on, so nothing is compared." The fix is the 2.0.2 adapter, published the same day as server 2.3.0.41

I tested it with the same fake server and the same guard options:2

// v2check.mjs: does the Express adapter actually enforce expectedResource?
import express from 'express';
import { readFileSync } from 'node:fs';
import { requireBearerAuth } from '@modelcontextprotocol/express';
import { SERVER_URL, makeVerifier } from './tokens.mjs';

const version = JSON.parse(readFileSync(new URL('./node_modules/@modelcontextprotocol/express/package.json', import.meta.url))).version;
const app = express();
const guard = requireBearerAuth({ verifier: makeVerifier({ mode: 'url' }), requiredScopes: ['mcp'], expectedResource: SERVER_URL });
app.post('/mcp', guard, (req, res) => res.json({ ok: true }));
const server = await new Promise(r => { const s = app.listen(0, '127.0.0.1', () => r(s)); });

for (const token of ['tok-ours', 'tok-other', 'tok-no-aud']) {
  const r = await fetch(`http://127.0.0.1:${server.address().port}/mcp`, { method: 'POST', headers: { authorization: `Bearer ${token}` } });
  console.log(`@modelcontextprotocol/express ${version}`, token.padEnd(10), r.status);
}
server.close();

With @modelcontextprotocol/server 2.3.1 installed next to each adapter version, I got this:2

@modelcontextprotocol/express 2.0.1 tok-ours   200
@modelcontextprotocol/express 2.0.1 tok-other  200
@modelcontextprotocol/express 2.0.1 tok-no-aud 200
@modelcontextprotocol/express 2.0.2 tok-ours   200
@modelcontextprotocol/express 2.0.2 tok-other  401
@modelcontextprotocol/express 2.0.2 tok-no-aud 401

Install both together: npm i @modelcontextprotocol/server@2.3.1 @modelcontextprotocol/express@2.0.2.

I also type-checked short calls with TypeScript 5.9.3. On 2.0.1 the compiler reported that expectedResource does not exist in type BearerAuthOptions (TS2353). On 2.0.2 it accepted a URL and rejected a string (TS2322).2 Plain JavaScript gives no warning, so a 2.0.1 install silently skips the check.

The same silent pass happened on v1. Install @modelcontextprotocol/sdk@1.31.0 and run the same matrix.mjs: the expectedResource column returns 200 for all ten tokens. If a lockfile pins 1.31.0, the option does nothing in plain JavaScript even when your code sets it, so check the installed version for MCP token audience validation.2

End to end: a real MCP client with a token for another server

The status codes come from a plain route. To make sure the behavior holds through the SDK's own client and a real tool call, I mounted a one-tool McpServer behind the guard on SDK 1.32.1.2

// e2e.mjs
import express from 'express';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';
import { makeVerifier, SERVER_URL } from './tokens.mjs';

async function start(expectedResource) {
  const app = express();
  app.use(express.json());
  const guard = requireBearerAuth({ verifier: makeVerifier({ mode: 'url' }), requiredScopes: ['mcp'], expectedResource,
    resourceMetadataUrl: 'https://mcp.example.com/.well-known/oauth-protected-resource/mcp' });
  app.post('/mcp', guard, async (req, res) => {
    const server = new McpServer({ name: 'demo', version: '1.0.0' });
    server.registerTool('read_notes', { description: 'Read private notes', inputSchema: {} },
      async () => ({ content: [{ type: 'text', text: `notes for ${req.auth.clientId}` }] }));
    const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
    res.on('close', () => { transport.close(); server.close(); });
    await server.connect(transport);
    await transport.handleRequest(req, res, req.body);
  });
  const s = await new Promise(r => { const x = app.listen(0, '127.0.0.1', () => r(x)); });
  return { s, url: new URL(`http://127.0.0.1:${s.address().port}/mcp`) };
}

async function call(url, token) {
  const client = new Client({ name: 'c', version: '1.0.0' });
  const t = new StreamableHTTPClientTransport(url, { requestInit: { headers: { Authorization: `Bearer ${token}` } } });
  try {
    await client.connect(t);
    const r = await client.callTool({ name: 'read_notes', arguments: {} });
    return 'OK: ' + r.content[0].text;
  } catch (e) { return 'ERR: ' + String(e.message).slice(0, 140).replace(/\n/g, ' '); }
  finally { await client.close().catch(() => {}); }
}

for (const [label, er] of [['without expectedResource', undefined], ['with expectedResource', SERVER_URL]]) {
  const { s, url } = await start(er);
  console.log(`--- ${label}`);
  for (const tok of ['tok-ours', 'tok-other']) console.log(tok.padEnd(10), await call(url, tok));
  if (er) {
    const r = await fetch(url, { method: 'POST', headers: { authorization: 'Bearer tok-other', 'content-type': 'application/json' }, body: '{}' });
    console.log('HTTP', r.status, '| WWW-Authenticate:', r.headers.get('www-authenticate'));
  }
  s.close();
}

The result:2

--- without expectedResource
tok-ours   OK: notes for client-1
tok-other  OK: notes for client-1
--- with expectedResource
tok-ours   OK: notes for client-1
tok-other  ERR: Streamable HTTP error: Error POSTing to endpoint: {"error":"invalid_token","error_description":"Token was not issued for this resource"}
HTTP 401 | WWW-Authenticate: Bearer error="invalid_token", error_description="Token was not issued for this resource", scope="mcp", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp"

Without the option, the client holding a token for another server called read_notes and got a normal result. With the option, the guard answered 401 and the client got an error instead of the tool result.

The 401 carries a WWW-Authenticate challenge with resource_metadata, the link the spec's authorization flow has clients follow to find the authorization server.3

MCP server OAuth setup checklist for token audience

Do these in order:

  1. Upgrade to SDK 1.32.1 or @modelcontextprotocol/server 2.3.1 (the option arrived in 1.32.0 and 2.3.0). On Express, upgrade @modelcontextprotocol/express to 2.0.2 or later.41
  2. Decide what URL your authorization server puts in aud for your server. Set expectedResource to that exact value, for example new URL('https://mcp.example.com/mcp').
  3. In your verifier, read the aud claim (or the introspection response's aud), pick your server's entry, and return it as new URL(...) in AuthInfo.resource. If your identifier is not something new URL() can parse, such as a bare GUID, the PR says to keep the comparison in your verifier and leave the option unset.5
  4. Always return expiresAt. The docs say a token with no expiresAt gets 401.4
  5. Test with a token minted for a different resource. If you get 200, the check is not doing its job.

In production your verifier would check a JWT signature or call token introspection first. My fake server skips that so the audience behavior stays visible.

What I did not test

These tests use a fake authorization server and a mock route. I did not test a real identity provider, JWT signature checking, or token introspection.

I ran Node 22.22.0 on Linux x86_64 only. The runtime tests are plain JavaScript, and the type checks were a few lines of TypeScript, not a real project.

I did not publish tests for the Hono or Fastify adapters, or for the web-standard requireBearerAuth fetch gate.

I did not test maxToolInputElements, which shipped in the same releases.1

Bottom line

The MCP spec says servers must check token audience. The TypeScript SDK now has an option for it, but it only works if you set it, your verifier reports resource, and your Express adapter is 2.0.2 or later.

For related setup, see the production MCP server with OAuth and Streamable HTTP tutorial and the MCP stateless protocol and enterprise authorization analysis. For the tool-side risk, see AI SDK tool drift detection and MCP rug pulls.

Footnotes

  1. modelcontextprotocol/typescript-sdk GitHub releases, read on 2026-10-06: 1.32.0 (published 2026-10-02), @modelcontextprotocol/server@2.3.0 (2026-10-02), 1.32.1 and v2.3.1 (2026-10-05). The quoted 1.32.0 lines are from that release's notes; the 2.3.0 and 2.3.1 wording matches the CHANGELOG.md entries in the repository's packages/server, packages/middleware/express and packages/server-legacy folders. Package publish times, from npm view <package> time: @modelcontextprotocol/sdk 1.32.0 on 2026-10-02, @modelcontextprotocol/express 2.0.2 on 2026-10-02. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9

  2. Author's measurement, October 6, 2026: Node 22.22.0 on Linux x86_64, Express 5.2.1, TypeScript 5.9.3, @modelcontextprotocol/sdk 1.31.0 and 1.32.1, @modelcontextprotocol/server 2.3.1 with @modelcontextprotocol/express 2.0.1 and 2.0.2. A fake authorization server with ten tokens; no real identity provider. The code in this post was extracted from the finished post and re-run in fresh folders, and the output matches. matrix.mjs was run three times per mode, string and url, with identical output within each mode. Figure 1 was drawn from runs of the same ten tokens; its two v2 columns came from a harness equivalent to v2check.mjs that sends all ten tokens. I read the installed bearerAuth.js of 1.32.1 for the order of checks, and searched the installed v1 server folder and the v2 server, express and core packages for reads of an aud claim; the only hit was a code comment in the v2 type definitions. The type checks were small requireBearerAuth calls compiled with --strict against each installed package, including a verifier that returns a string resource on 1.32.1. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 ↩15 ↩16 ↩17 ↩18 ↩19 ↩20 ↩21 ↩22 ↩23

  3. Model Context Protocol, "Authorization" (Token Handling section) and "Authorization Security Considerations" (Token Audience Binding and Validation; Access Token Privilege Restriction), specification version 2026-07-28, fetched 2026-10-06. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7

  4. modelcontextprotocol/typescript-sdk, docs/serving/authorization.md on the main branch, read 2026-10-06, including its guidance on expectedResource, AuthInfo.resource and @modelcontextprotocol/express 2.0.1. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10

  5. Pull request #2929, "feat(server): add expectedResource to the bearer-token check", opened and merged 2026-10-02, fetched 2026-10-06. ↩ ↩2 ↩3 ↩4

  6. Model Context Protocol, "Versioning", "Revisions" section, which states that the current protocol version is 2026-07-28. Fetched 2026-10-06. ↩

  7. IETF, RFC 8707, "Resource Indicators for OAuth 2.0", Proposed Standard, February 2020; authors Brian Campbell, John Bradley and Hannes Tschofenig. ↩

  8. Model Context Protocol, "Authorization", "Resource Parameter Implementation" and "Canonical Server URI" sections, specification version 2026-07-28, fetched 2026-10-06. ↩ ↩2

Frequently Asked Questions

Yes. The 2026-07-28 authorization page says servers must validate that access tokens were issued specifically for them as the intended audience, per RFC 8707 Section 2.3