Skip to main content
Back to Blog

Test MCP Authorization Beyond a Valid Access Token

A verifier that checks only signature and expiry accepted a token minted for the downstream API, a token from an unknown issuer and an ID token, then forwarded the caller's token on every write. Build the token matrix that catches each of those against an MCP 2026-07-28 server, and see why a per-object denial must be a tool error rather than an insufficient_scope 403.

Shashank Rawlani

Engineering Leader | Builder | Problem Solver | AI Engineer

Published Oct 30, 202618 min read
View AI-ready Markdown
Dark technical illustration reading left to right. Four small cards are stacked on the left, each marked with the same green ring in its corner. Only the third card from the top is green and has a bright green pill level with a narrow slot cut into a tall green wall; the other three cards are grey with orange pills at other heights, and their orange lines stop at crossed-out circles in front of the wall. The green card's line passes through the slot to a ringed node, which sends a line through a second, taller card with a square green seal into a panel of stacked bars on the right. A dashed orange arc leaves the node over the top towards that panel and ends at a crossed-out circle before reaching it.
Listen to this article · 20 min
Quick answer: A valid signature and an unexpired exp do not prove a token was issued for your MCP server, for this tool, or for this user's data. Test authorization as a matrix of tokens the server must refuse: wrong audience, wrong issuer, wrong token type, expired, missing scope, and enough scope for an object the user cannot touch. Assert the exact answer: 401 with error="invalid_token" for a bad token, 403 with error="insufficient_scope" and the missing scopes for a thin one, and a tool error, not a scope challenge, for a per-object denial. If the server calls another API, assert that the token arriving there was issued for that API and is never the one your client sent.

An MCP server sits in front of a project-management API and exposes two tools, list_issues and update_issue. Its authorization tests pass: no token gets a 401, an expired token gets a 401, a valid token lists issues. Then someone replays a token that was minted for the project-management API itself, and the server accepts it. A token from an unknown issuer also gets through, and so does an OpenID Connect ID token. On every write, the server forwards the caller's own token to the downstream API. Every one of those tokens had a valid signature.

mode=naive
FAIL 200 aud = PM API (replayed downstream token)             ok: PAY: 1 open issue
FAIL 200 iss = unknown issuer                                 ok: PAY: 1 open issue
FAIL 200 typ JWT (an ID token, not an access token)           ok: PAY: 1 open issue
downstream /issues/PAY-12?status=done aud=https://mcp.example.test/mcp iss=https://auth.example.test
downstream /issues/PAY-12?status=done aud=https://mcp.example.test/mcp iss=https://auth.example.test
FAIL 2 downstream call(s) carried a token not issued for the PM API
10 rows, 4 failed

That excerpt comes from the lab behind this article, run against a verifier that checks only the signature and exp. Part 1 of this series laid out the layers of an MCP test strategy, and part 2 put the official conformance suite into CI. This part covers what the conformance suite cannot write for you: which tokens your server accepts, and what each tool allows once it has.

Checked on 2026-10-26: this article targets MCP 2026-07-28, the revision the specification site lists as current. The lab uses @modelcontextprotocol/server 2.4.0 (published 2026-10-23), jose 6.2.13 and tsx 4.23.15 on Node.js 26.0.0, macOS arm64. Requests are web-standard Request objects handed straight to the server's fetch handler, so nothing crosses a network and every host is a fictional .example.test name. The authorization server is only a test signing key, and the consent endpoint is a 30-line model, not deployable code.

Four parties, and the model is not one of them

In the 2026-07-28 authorization specification, a protected MCP server is an OAuth 2.1 resource server, the MCP client is an OAuth client acting for a resource owner, and an authorization server issues the tokens. A proxy like the one above adds a fourth party: the downstream API, which trusts its own authorization server and nobody else's tokens. Each party rejects different things, so each has its own tests:

PartyWhat it must refuseWhere the test lives
MCP server (resource server)Tokens not issued for it, tokens without the scope a tool needs, calls on objects the user cannot reachYour suite: this article
MCP clientProtected resource metadata whose resource does not match, authorization responses with the wrong issThe client's own tests, and the conformance suite's client role
Authorization serverRedirect URIs that are not an exact match, clients the user never approvedYour suite if you run one; the conformance requirement sets exclude it
Downstream APIAny token its own authorization server did not issue for itIts owners, plus your assertion on what you send it

The language model is absent from that table. It may choose a tool and its arguments, but it never decides whether a token is good enough: the client takes scopes from the server's challenge, and the server checks claims and object access on every call. A test that depends on the model's judgement about authorization is testing the wrong component.

Start with what the server publishes

The specification requires MCP servers to implement RFC 9728 Protected Resource Metadata, advertised through the resource_metadata parameter of the 401 challenge or at a well-known URI, where clients try the path-specific location before the root. The document must list at least one authorization server. RFC 9728 section 3.3 makes it trustworthy: its resource value must be identical to the resource identifier the metadata URL was built from, or the client must not use it. The lab's probe:

200 /.well-known/oauth-protected-resource/mcp
    resource=https://mcp.example.test/mcp identical=true authorization_servers=["https://auth.example.test"] scopes_supported=["issues:read"]
401 /.well-known/oauth-protected-resource

Assert three things: resource equals the canonical server URI exactly, as a string; the authorization server list is not empty; and scopes_supported is the small set a client needs to start. When a 401 carries no scope, the specification tells clients to request everything in scopes_supported, so a server that publishes its whole catalogue there has asked every client for every permission. The security best-practices page lists that among the common scope mistakes.

Now the challenge for the no-token row:

PASS 401 no token
         WWW-Authenticate: Bearer error="invalid_token", error_description="Missing Authorization header", resource_metadata="https://mcp.example.test/.well-known/oauth-protected-resource/mcp"

RFC 6750 section 3.1 says that when a request carries no authentication information at all, the server SHOULD NOT include an error code. The SDK's bearer gate sends invalid_token anyway. Clients still find the metadata, because they key on the 401 and resource_metadata, so the lab asserts only those two. A contract test that applies RFC 6750 to the letter fails this row on SDK 2.4.0. Decide which behaviour you hold the server to and write it down, rather than letting the assertion drift to whatever the server emits.

Mint the tokens the server has to refuse

A correctly configured authorization server never issues the bad tokens, so mint them yourself with a test key and put its public half in the server's key set under test:

// issuer.ts: a test-only authorization server key and a token minter.
import { SignJWT, generateKeyPair, exportJWK, type JWK } from 'jose';

export const AS_ISSUER = 'https://auth.example.test';
export const MCP_RESOURCE = 'https://mcp.example.test/mcp';
export const PM_API = 'https://pm.example.test/api';

const { privateKey, publicKey } = await generateKeyPair('ES256');
export const publicJwk: JWK = { ...(await exportJWK(publicKey)), kid: 'test-1', alg: 'ES256' };

type Mint = { aud?: string | string[]; iss?: string; scope?: string; sub?: string;
              client_id?: string; expiresIn?: number; typ?: string };

export async function mint(m: Mint = {}): Promise<string> {
  const now = Math.floor(Date.now() / 1000);
  return new SignJWT({ scope: m.scope ?? 'issues:read', client_id: m.client_id ?? 'agent-host' })
    .setProtectedHeader({ alg: 'ES256', kid: 'test-1', typ: m.typ ?? 'at+jwt' })
    .setIssuer(m.iss ?? AS_ISSUER)
    .setAudience(m.aud ?? MCP_RESOURCE)
    .setSubject(m.sub ?? 'u-asha')
    .setIssuedAt(now)
    .setExpirationTime(now + (m.expiresIn ?? 300))
    .setJti(crypto.randomUUID())
    .sign(privateKey);
}

Each parameter exists to break one check. RFC 9068, the JWT access token profile, gives a resource server its validation list: the typ header must be at+jwt or application/at+jwt, iss must exactly match the authorization server's issuer, aud must contain an identifier the resource server expects for itself, alg must not be none, and the current time must be before exp. The MCP specification adds that a server must validate it is the intended audience, per RFC 8707, and must answer an invalid or expired token with a 401. The two verifiers the lab compares differ by four options:

// Wrong: proves a trusted key signed it and it has not expired. Nothing else.
const { payload } = await jwtVerify(token, jwks);

// Right: the token was issued by our AS, for this server, as an access token.
const { payload } = await jwtVerify(token, jwks, {
  issuer: AS_ISSUER,
  audience: MCP_RESOURCE,
  typ: 'at+jwt',
  algorithms: ['ES256'],
});
return {
  token,
  clientId: String(payload.client_id),
  scopes: String(payload.scope ?? '').split(' ').filter(Boolean),
  expiresAt: payload.exp,
  resource: new URL(MCP_RESOURCE), // only reached when aud contained MCP_RESOURCE
  extra: { sub: payload.sub },
};

The resource line matters because of how the TypeScript SDK's bearer gate is built. In @modelcontextprotocol/server 2.4.0, requireBearerAuth never reads aud itself. When you pass expectedResource, it compares that value with the AuthInfo.resource your verifier returns, ignoring a fragment and one trailing slash. Leave expectedResource out and nothing is compared. That gives two quiet failure modes: no option and a verifier that never checked aud, which the wrong-audience row catches, or the option set and resource never filled, which refuses every token and fails the valid-token row.

const gate = requireBearerAuth({
  verifier: strictVerifier,
  resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(new URL(MCP_RESOURCE)),
  expectedResource: new URL(MCP_RESOURCE),
});
return async (request: Request) => {
  const wellKnown = oauthMetadataResponse(request, metadata);
  if (wellKnown) return wellKnown;
  const auth = await gate(request);
  if (auth instanceof Response) return auth;      // the 401 or 403 challenge
  return handler.fetch(request, { authInfo: auth });
};

One question the minted tokens cannot answer is what your real authorization server puts in aud. RFC 8707 lets it use the exact resource value the client sent, or map that value to a more general identifier. Decode one real token from staging with the JWT Decoder before you hard-code the expected audience, and read why a decoded payload proves nothing on its own.

The matrix, run through the real tool path

Every row is a full tools/call over Streamable HTTP, with the 2026-07-28 headers and _meta envelope, so the request goes through the same gate and per-tool checks a client would hit. A row names its token, the tool and arguments, and the status, challenge and outcome it expects:

{ name: 'aud = PM API (replayed downstream token)', token: () => mint({ aud: PM_API }),
  tool: 'list_issues', args: { project: 'PAY' }, expect: 401, expectChallenge: /invalid_token/ },
{ name: 'read scope calls update_issue', token: () => mint(),
  tool: 'update_issue', args: { issueId: 'PAY-12', status: 'done' },
  expect: 403, expectChallenge: /error="insufficient_scope".*scope="issues:write"/ },
{ name: 'write scope, issue in a project the user is not in', token: () => mint({ scope: 'issues:write' }),
  tool: 'update_issue', args: { issueId: 'OPS-7', status: 'done' }, expect: 200, expectOutcome: /^tool error/ },

Against the strict server, all ten rows pass:

mode=strict
PASS 401 no token                                             -
PASS 200 read token, own project                              ok: PAY: 1 open issue
PASS 401 aud = PM API (replayed downstream token)             -
         WWW-Authenticate: Bearer error="invalid_token", error_description="unexpected \"aud\" claim value", resource_metadata="https://mcp.example.test/.well-known/oauth-protected-resource/mcp"
PASS 401 iss = unknown issuer                                 -
PASS 401 expired 60s ago                                      -
PASS 401 typ JWT (an ID token, not an access token)           -
PASS 403 read scope calls update_issue                        -
         WWW-Authenticate: Bearer error="insufficient_scope", error_description="update_issue needs issues:write", scope="issues:write", resource_metadata="https://mcp.example.test/.well-known/oauth-protected-resource/mcp"
PASS 200 write scope, issue in a project the user is not in   tool error: Not permitted to change OPS-7.
PASS 200 write scope, own issue                               ok: PAY-12 -> done
PASS 200 admin scope (implies write), own issue               ok: PAY-12 -> done
downstream /issues/PAY-12?status=done aud=https://pm.example.test/api iss=https://pm-auth.example.test
downstream /issues/PAY-12?status=done aud=https://pm.example.test/api iss=https://pm-auth.example.test
10 rows, 0 failed

Some challenge lines are trimmed; every 401 carried invalid_token and the metadata URL. The error_description values are jose's own messages, which make failures easy to read and also tell a caller exactly which check failed. Decide whether production should say that much.

The opening failure was the naive verifier in this same harness, and its expired row passed too, because a signature-and-expiry check does catch expiry. That is how a suite with only "no token" and "expired token" cases stays green over a server that accepts anything a trusted key signed.

A 403 means "get a bigger token"; a tool error means "no"

The specification describes a runtime scope challenge: when a token lacks scope for an operation, the server SHOULD answer 403 with a Bearer challenge carrying error="insufficient_scope", the scope the operation needs, and resource_metadata. A client acting for a user SHOULD then re-authorize with the union of its previous scopes and the challenged ones, and retry only a few times before treating it as a permanent failure. In the SDK, a per-tool check is a scopeChallenge handler on the tool:

// issues:admin implies issues:write; the spec makes the server account for that.
const writeScope: ScopeChallengeHandler = ({ authInfo }) => {
  if (!authInfo) return;
  const s = new Set(authInfo.scopes);
  if (s.has('issues:write') || s.has('issues:admin')) return;
  return { scopes: ['issues:write'], errorDescription: 'update_issue needs issues:write' };
};

server.registerTool('update_issue', {
  description: 'Change the status of one issue.',
  inputSchema: z.object({ issueId: z.string(), status: z.enum(['open', 'done']) }),
  scopeChallenge: writeScope,
}, async ({ issueId, status }, ctx) => {
  const auth = ctx.http!.authInfo!;
  const project = issueProject[issueId];
  if (!project || !members[project].includes(String(auth.extra?.sub))) {
    return { isError: true, content: [{ type: 'text', text: `Not permitted to change ${issueId}.` }] };
  }
  // ...call the downstream API with its own token
});

Two decisions in that code are worth a test each.

Object access is not a scope. A user with issues:write asking to change an issue in a project they do not belong to is denied with a tool error. Answer it with an insufficient_scope 403 instead, and the client does what the specification tells it to: asks for issues:write again, gets it, retries, and gets the same 403. The security best-practices page lists "treating claimed scopes in token as sufficient without server-side authorization logic" as a common mistake; dressing a policy denial up as a scope problem is the reverse mistake, and it sends clients round the step-up loop. So a scope row expects a 403 with the challenge, and a policy row expects a 200 whose result has isError: true. The object, function and property authorization grid is a good way to enumerate the policy rows per tool.

The 2026-07-28 revision removed protocol-level sessions, so a server that needs state across calls mints a handle, such as a workflow ID, and gets it back as an ordinary tool argument. The best-practices page says servers MUST NOT treat possession of a handle as authentication and SHOULD bind it to the user from the verified token. A stolen handle is one more policy row: user B presenting user A's handle gets the same tool error as user B naming user A's issue.

Scope hierarchies are the server's job. The specification says servers MUST account for scope hierarchies, where a broader scope implies narrower ones, when deciding whether a token is sufficient. The SDK ships a requireScopes(...) helper, and its documentation says it requires every supplied scope exactly. Swap it into the same tool and the admin row breaks:

mode=exact
FAIL 403 admin scope (implies write), own issue               -
         WWW-Authenticate: Bearer error="insufficient_scope", error_description="Insufficient scope", scope="issues:write", resource_metadata="https://mcp.example.test/.well-known/oauth-protected-resource/mcp"
10 rows, 1 failed

That administrator now sits through a consent prompt for a permission they already hold. The helper is fine for flat scopes; the test is what tells you whether your scopes are flat.

When a tool needs several scopes, the specification also asks servers to name all of them in one challenge, because each partial challenge costs the user another authorization round trip. A token holding neither of two required scopes should get one 403 naming both.

Prove the downstream call carries a different token

If an MCP server calls an upstream API, the specification says the token it uses there is a separate one issued by that API's authorization server, and the server MUST NOT pass through the token it received from the client. The best-practices page calls this token passthrough: controls that depend on the token's audience get bypassed, audit logs show the wrong identity, and a stolen token can use the proxy for data exfiltration.

Test it at the downstream boundary. The lab records the Authorization header of every call the proxy makes to the project-management API and fails the run if any token's audience is not that API:

for (const c of pmCalls) {
  const claims = decodeJwt(c.auth.replace(/^Bearer /, ''));
  console.log(`downstream ${c.path} aud=${claims.aud} iss=${claims.iss}`);
}
const leaked = pmCalls.filter(c => decodeJwt(c.auth).aud !== PM_API).length;
if (leaked) { failed++; console.log(`FAIL ${leaked} downstream call(s) carried a token not issued for the PM API`); }

Decoding is enough here, because the question is which token was sent, not whether it is valid. In the strict run both downstream calls carry the API's own audience and issuer; in the naive run they carry the MCP token unchanged. In a real suite, point the proxy's downstream base URL at a recording stub and keep this assertion in every write-tool test.

The inbound audience check is the other half. RFC 9728 section 7.4 describes what audience restriction prevents: a malicious resource server gets a client to request a token with a scope another resource server uses, then replays that token there. The wrong-audience row is that replay.

Some proxies register once at the third-party authorization server with a static client ID and run their own authorization endpoint for MCP clients. The best-practices page describes the confused deputy that follows when four conditions hold together: a static client ID upstream, dynamically registered MCP clients, a consent cookie set by the third-party server, and no per-client consent at the proxy. An attacker registers a client with their own redirect URI and sends the user a link; the third-party server sees its cookie, skips consent, and the authorization code ends up with the attacker.

The required protections are plain request-and-response rows: consent kept per user and per client_id, checked before forwarding upstream, and redirect_uri matched against the registered value by exact string comparison, not pattern or wildcard. The core of the lab's model:

const prefixMatch = (allowed: string[], uri: string) => allowed.some(a => uri.startsWith(a));
const exactMatch = (allowed: string[], uri: string) => allowed.includes(uri);

const ok = opts.exactRedirect ? exactMatch(allowed, redirectUri) : prefixMatch(allowed, redirectUri);
if (!ok) return { status: 400, body: 'invalid_request: redirect_uri' };
if (opts.perClientConsent && !consented.has(`${user}:${clientId}`)) {
  return { status: 200, body: `consent page: ${clientId} wants issues on your behalf, tokens go to ${redirectUri}` };
}
exactRedirect=false perClientConsent=false
PASS 302 registered client, exact redirect            Location: https://pm-auth.example.test/authorize?client_id=issues-proxy
FAIL 302 registered client, redirect with extra path  Location: https://pm-auth.example.test/authorize?client_id=issues-proxy
FAIL 302 registered client, redirect plus ?next=      Location: https://pm-auth.example.test/authorize?client_id=issues-proxy
FAIL 302 new DCR client, user has no proxy consent    Location: https://pm-auth.example.test/authorize?client_id=issues-proxy
exactRedirect=true perClientConsent=true
PASS 302 registered client, exact redirect            Location: https://pm-auth.example.test/authorize?client_id=issues-proxy
PASS 400 registered client, redirect with extra path  invalid_request: redirect_uri
PASS 400 registered client, redirect plus ?next=      invalid_request: redirect_uri
PASS 200 new DCR client, user has no proxy consent    consent page: dcr-7f3a wants issues on your behalf, tokens go to https://collector.example.test/cb

The last row is the confused deputy. Without per-client consent, the brand-new client goes straight upstream under the proxy's static client ID, where the user's earlier consent cookie does the rest. The best-practices page also fixes the order of state handling: the cookie or session holding it must not be set until the user approves the proxy's consent screen, and the callback must reject a missing or mismatched value. That ordering lives in cookies across several responses, so it needs a browser-level test.

The 2026-07-28 revision deprecates Dynamic Client Registration in favour of Client ID Metadata Documents but keeps it for authorization servers without the newer mechanism, so these rows apply wherever registration is still dynamic. If your MCP server only validates tokens from an external authorization server, this section is that server's test plan, not yours.

What this suite costs, and what it cannot see

The matrix grows with tools times scopes times ownership cases, and a new tool adds rows nobody writes unless review demands them. Keeping the test signing key, expected issuer and expected audience in one module, as issuer.ts does, stops a rename from silently weakening every row.

It also stops at your server's edge. Minted tokens say nothing about whether your authorization server honours the resource parameter, which clients must send in both the authorization and token requests, so run one staging row that obtains a real token through the full flow and asserts its aud. Client behaviour, such as rejecting mismatched metadata or checking iss on the authorization response, is client-side testing. And none of this applies to a stdio server: the specification says stdio implementations SHOULD NOT follow this flow and should take credentials from the environment.

Build the first five rows today

Pick the MCP server with the most dangerous write tool. Mint five tokens with a test key: audience set to the downstream API, an unknown issuer, typ: JWT, expired, and read-only scope against the write tool. Assert 401 with invalid_token for the first four and a 403 naming the missing scope for the fifth. Add one policy row, a valid write token aimed at an object the user does not own, and assert a tool error rather than a 403. If the server calls another API, record the outbound Authorization header and fail on any token whose audience is not that API.

The evidence to keep is the printed matrix, one line per row naming what is wrong with the token, the tool, the status and the outcome, attached to every pull request that touches authorization. A reviewer who sees a row flip from 401 to 200 knows what changed without reading the diff.

Questions about MCP authorization tests

Does the conformance suite already cover this?

It covers part of it. The conformance repository's requirement sets name the scenarios for an MCP server acting as a resource server and an MCP client acting as an OAuth client, and deliberately exclude authorization-server scenarios. A protocol-level suite cannot know that update_issue needs issues:write, or that Asha may not touch project OPS. Part 2 covers running it; the rows in this article sit next to it.

Can I make the bad tokens by editing claims in a real one?

No. Changing aud in a signed token invalidates the signature, so the server rejects it for the signature and the row passes for the wrong reason. The naive verifier in this article would reject that token too. Every refusal row needs a token signed by a key the server trusts, which is why the lab mints them.

How do I test that a client does not loop on scope challenges?

Return the same insufficient_scope challenge on every retry from a stub server and count the authorization attempts. The specification says a client should retry no more than a few times and treat the result as a permanent failure, and the best-practices page tells clients to cache recent failures so they do not loop on denied scopes. A count that keeps rising is the bug.

Primary references