# 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.

- Author: [Shashank Rawlani](https://shashank.rawlani.com)

- Published: 2026-10-30T13:30:00.000Z

- Updated: 2026-10-30T13:30:00.000Z

- Category: AI in Testing

- Canonical source: https://automationtester.in/blog/ai-in-testing/test-mcp-authorization-resource-audience-scopes

- Tags: mcp, oauth, access-tokens, ai-agents, security-testing, api-authorization

**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](/blog/ai-in-testing/how-to-test-mcp-server-protocol-auth-safety) laid out the layers of an MCP test strategy, and [part 2](/blog/ai-in-testing/mcp-conformance-framework-ci) 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:

| Party | What it must refuse | Where 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 reach | Your suite: this article |
| MCP client | Protected resource metadata whose `resource` does not match, authorization responses with the wrong `iss` | The client's own tests, and the conformance suite's client role |
| Authorization server | Redirect URIs that are not an exact match, clients the user never approved | Your suite if you run one; the conformance requirement sets exclude it |
| Downstream API | Any token its own authorization server did not issue for it | Its 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](/tools/jwt-decoder) before you hard-code the expected audience, and read [why a decoded payload proves nothing on its own](/blog/tools-reviews/inspect-jwt-claims-without-trusting-decode).

## 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](/blog/automation-tutorials/api-authorization-testing-object-function-property) 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.

## When the proxy runs its own authorization endpoint

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](/blog/ai-in-testing/mcp-conformance-framework-ci) 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

- [MCP authorization, revision 2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization). Roles, the resource parameter in authorization and token requests, canonical URIs, audience validation, 401 for invalid or expired tokens, no passthrough or transit of other tokens, the 401/403/400 table, the insufficient_scope challenge, single challenges with all required scopes, scope-union step-up with limited retries, scope hierarchies, and stdio servers taking credentials from the environment.

- [MCP authorization server discovery, 2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/authorization-server-discovery). Mandatory Protected Resource Metadata with at least one authorization server, the WWW-Authenticate and well-known discovery mechanisms, and the path-then-root order.

- [MCP authorization security considerations, 2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/security-considerations). Exact redirect URI validation, per-client consent for proxies with static client IDs, and the rule that an MCP server must not pass through the token it received.

- [MCP security best practices](https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices). The four confused-deputy conditions, consent and state ordering, exact string redirect matching, token passthrough risks, state handle binding, and the common scope mistakes.

- [Key changes in MCP 2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28/changelog). Removal of protocol-level sessions in favour of server-minted handles, and the deprecation of Dynamic Client Registration.

- [MCP versioning](https://modelcontextprotocol.io/specification/versioning). 2026-07-28 as the current protocol version on the check date.

- [RFC 9728, OAuth 2.0 Protected Resource Metadata](https://www.rfc-editor.org/rfc/rfc9728). The identical-resource validation rule, the resource_metadata challenge parameter, and the audience-restriction attack in section 7.4.

- [RFC 8707, Resource Indicators for OAuth 2.0](https://www.rfc-editor.org/rfc/rfc8707). The resource parameter, and an authorization server's freedom to map a resource to a more general audience.

- [RFC 9068, JWT Profile for OAuth 2.0 Access Tokens](https://www.rfc-editor.org/rfc/rfc9068). The resource server's validation list: typ at+jwt, exact issuer, audience containment, no alg none, and expiry.

- [RFC 6750, Bearer Token Usage](https://www.rfc-editor.org/rfc/rfc6750). WWW-Authenticate error codes and statuses, and no error code when a request carries no credentials.

- [TypeScript SDK bearer auth at v2.4.0](https://github.com/modelcontextprotocol/typescript-sdk/blob/v2.4.0/packages/server/src/server/middleware/bearerAuth.ts). expectedResource compared with AuthInfo.resource, unset meaning no comparison, and the missing-header challenge.

- [TypeScript SDK scope challenges at v2.4.0](https://github.com/modelcontextprotocol/typescript-sdk/blob/v2.4.0/packages/server/src/server/scopeChallenge.ts). The requireScopes helper requiring every supplied scope exactly.

- [MCP conformance repository](https://github.com/modelcontextprotocol/conformance). Requirement sets for the resource-server and client roles, excluding authorization-server scenarios.

## Continue reading on AutomationTester.in

- [Check MCP Tool Schemas Before an Agent Calls Them](https://automationtester.in/blog/tools-reviews/check-mcp-tool-schemas-before-agents-call-them)
- [Inspect JWT Claims Without Trusting the Decode](https://automationtester.in/blog/tools-reviews/inspect-jwt-claims-without-trusting-decode)
- [Test Mobile Deep Links and Universal Links End to End](https://automationtester.in/blog/automation-tutorials/test-mobile-deep-links-universal-links)
- [Use Appium MCP Without Confusing Exploration and Tests](https://automationtester.in/blog/ai-in-testing/appium-mcp-exploration-vs-test-suite)
- [Validate AI Agent Tool Calls in TypeScript: Schema, Authorization, Approval](https://automationtester.in/blog/ai-in-testing/validate-ai-agent-tool-calls-typescript)

Source: [Test MCP Authorization Beyond a Valid Access Token](https://automationtester.in/blog/ai-in-testing/test-mcp-authorization-resource-audience-scopes) by Shashank Rawlani.
