Resource library

QA How-To

How to Test MCP Server OAuth Authorization (2026)

Learn to test MCP server OAuth authorization with PKCE, resource discovery, audience validation, negative cases, and runnable Vitest test examples today.

24 min read | 3,068 words

TL;DR

Probe the MCP endpoint without a token, validate protected resource and authorization-server metadata, exercise Authorization Code with S256 PKCE, then test token signature, issuer, audience, time, scopes, and method-level denials.

Key Takeaways

  • Test discovery, token issuance, validation, and MCP permissions as separate trust boundaries.
  • Verify RFC 9728 metadata against the exact canonical MCP resource URI.
  • Send the resource indicator in both authorization and token requests.
  • Use Authorization Code with S256 PKCE for public MCP clients.
  • Cryptographically verify signature, issuer, audience, expiry, and scopes.
  • Expect 401 for invalid credentials and 403 for insufficient permission.
  • Keep bearer credentials short-lived, minimally scoped, and absent from logs.

To test MCP server OAuth authorization, prove the entire trust boundary rather than merely obtaining an access token. Verify protected-resource discovery, authorization-server discovery, Authorization Code with PKCE, resource indicators, cryptographic token validation, scopes, and safe failures through the same Streamable HTTP endpoint an MCP client uses.

This tutorial builds a runnable Node.js and Vitest black-box suite for a staging MCP server. It follows the MCP 2025-11-25 authorization specification and keeps production credentials out of automation. Read the MCP security testing guide for the wider threat model and building an MCP server for test automation for server context.

TL;DR

Boundary Positive evidence Negative evidence
MCP resource Authorized initialize returns a result Missing token returns no protected data
RFC 9728 metadata Exact resource URI and trusted issuer Mismatched resource is unusable
OAuth metadata HTTPS endpoints and S256 support Unexpected issuer is rejected
Code exchange Code, verifier, redirect URI, and resource agree Wrong verifier returns invalid_grant
Access token Signature, issuer, audience, time, and scope pass One invalid property returns 401
MCP permission Allowed tool succeeds Valid under-scoped token returns 403

What You Will Build

You will create a suite that:

  • probes an MCP endpoint without credentials;
  • validates RFC 9728 protected resource metadata;
  • discovers and pins the OAuth or OpenID Connect issuer;
  • constructs an Authorization Code request with S256 PKCE;
  • exchanges a disposable test code with a resource indicator;
  • verifies JWT signature, issuer, audience, time, and scope;
  • calls MCP initialize with valid and malformed bearer tokens;
  • runs safe discovery and credential-bearing checks in CI.

Prerequisites

Use Node.js 22.18.0 or newer in the Node 22 LTS line, npm 10.9.3 or newer, Vitest 3.2.4, and jose 6.0.12. Node 22 supplies stable fetch, URL, and Web Crypto APIs.

mkdir mcp-oauth-tests
cd mcp-oauth-tests
npm init -y
npm install --save-dev vitest@3.2.4
npm install jose@6.0.12

Add "type": "module" and "test:oauth": "vitest run test/mcp-oauth.test.js" to package.json. Register a staging OAuth client for Authorization Code plus PKCE with redirect URI http://127.0.0.1:43119/callback. Use a disposable identity with non-destructive MCP permissions.

Verification: run node --version && npm --version && npx vitest --version. Expect Node 22.18.0 or later, npm 10.9.3 or later, and Vitest 3.2.4.

Step 1: Define the Exact Resource Boundary

Create test/config.js:

export const config = {
  mcpUrl: new URL(process.env.MCP_URL ??
    "https://mcp.staging.example.com/mcp"),
  clientId: process.env.OAUTH_CLIENT_ID ?? "mcp-oauth-test-client",
  redirectUri: process.env.OAUTH_REDIRECT_URI ??
    "http://127.0.0.1:43119/callback",
  expectedIssuer: process.env.OAUTH_ISSUER
    ? new URL(process.env.OAUTH_ISSUER) : null,
  accessToken: process.env.MCP_ACCESS_TOKEN ?? null,
  authorizationCode: process.env.OAUTH_AUTHORIZATION_CODE ?? null,
  codeVerifier: process.env.OAUTH_CODE_VERIFIER ?? null
};

export function canonicalResource() {
  const value = new URL(config.mcpUrl);
  value.hash = "";
  value.search = "";
  return value.href;
}

Preserve a path such as /mcp. RFC 8707 recommends the most specific canonical URI available, and the audience must identify the server the client will actually use. Removing a query and fragment prevents accidental environment-specific audience values.

Verification: run MCP_URL=https://example.com/mcp?debug=1 node -e "import('./test/config.js').then(m=>console.log(m.canonicalResource()))". Expect https://example.com/mcp.

Step 2: Test MCP Server OAuth Authorization Discovery

Create test/discovery.js:

export function protectedResourceMetadataUrl(resourceValue) {
  const url = new URL(resourceValue);
  const path = url.pathname === "/" ? "" : url.pathname.replace(/\/$/, "");
  url.pathname = `/.well-known/oauth-protected-resource${path}`;
  url.search = "";
  url.hash = "";
  return url;
}

export async function discoverProtectedResource(mcpUrl) {
  const probe = await fetch(mcpUrl, {
    method: "POST",
    headers: {
      "content-type": "application/json",
      "accept": "application/json, text/event-stream"
    },
    body: JSON.stringify({
      jsonrpc: "2.0",
      id: "unauthenticated-probe",
      method: "initialize",
      params: {
        protocolVersion: "2025-11-25",
        capabilities: {},
        clientInfo: { name: "oauth-test", version: "1.0.0" }
      }
    })
  });
  const challenge = probe.headers.get("www-authenticate") ?? "";
  const match = challenge.match(/resource_metadata="([^"]+)"/i);
  const metadataUrl = match
    ? new URL(match[1])
    : protectedResourceMetadataUrl(mcpUrl);
  const response = await fetch(metadataUrl);
  if (!response.ok) throw new Error(
    `Protected resource metadata returned ${response.status}`
  );
  return { probe, metadataUrl, metadata: await response.json() };
}

RFC 9728 inserts the well-known segment before a resource path. Thus https://host/mcp maps to https://host/.well-known/oauth-protected-resource/mcp. Current MCP permits fallback to that URL when a 401 challenge omits resource_metadata, so test both discovery routes rather than requiring an optional header parameter.

Verification: import discoverProtectedResource, call it with MCP_URL, and print metadata. Expect a resource string and a non-empty authorization_servers array.

Step 3: Assert the Protected Resource Contract

Create test/mcp-oauth.test.js:

import { beforeAll, describe, expect, test } from "vitest";
import { createHash, randomBytes } from "node:crypto";
import { createRemoteJWKSet, decodeJwt, jwtVerify } from "jose";
import { config, canonicalResource } from "./config.js";
import {
  discoverProtectedResource,
  protectedResourceMetadataUrl
} from "./discovery.js";

let discovery;
let authorizationMetadata;

describe("MCP OAuth authorization", () => {
  beforeAll(async () => {
    discovery = await discoverProtectedResource(config.mcpUrl);
  });

  test("denies an unauthenticated initialize request", async () => {
    expect([401, 403]).toContain(discovery.probe.status);
    expect(await discovery.probe.clone().text()).not.toContain('"result"');
  });

  test("publishes valid protected resource metadata", () => {
    expect(discovery.metadataUrl.href).toBe(
      protectedResourceMetadataUrl(config.mcpUrl).href
    );
    expect(discovery.metadata.resource).toBe(canonicalResource());
    expect(discovery.metadata.authorization_servers).toEqual(
      expect.arrayContaining([expect.stringMatching(/^https:\/\//)])
    );
  });
});

Exact equality is deliberate. RFC 9728 tells clients not to use metadata when its resource differs from the resource identifier used to retrieve it. Do not trim slashes in the assertion simply to make inconsistent deployments pass. The unauthenticated assertion also examines the body because a status alone does not prove that protected JSON-RPC data was withheld.

Verification: run npm run test:oauth -- -t "protected resource metadata". Expect one passing test or a precise URI mismatch.

Step 4: Discover and Pin the Authorization Server

Add these helpers above describe:

async function fetchAuthorizationMetadata(issuerValue) {
  const issuer = new URL(issuerValue);
  const suffix = issuer.pathname === "/"
    ? "" : issuer.pathname.replace(/\/$/, "");
  const candidates = [
    new URL(`/.well-known/oauth-authorization-server${suffix}`, issuer),
    new URL(`/.well-known/openid-configuration${suffix}`, issuer)
  ];
  for (const url of candidates) {
    const response = await fetch(url);
    if (response.ok) return response.json();
  }
  throw new Error(`No authorization metadata for ${issuer.href}`);
}

function assertTrustedIssuer(value) {
  const issuer = new URL(value);
  expect(issuer.protocol).toBe("https:");
  if (config.expectedIssuer) {
    expect(issuer.href).toBe(config.expectedIssuer.href);
  }
}

Extend beforeAll:

const issuer = discovery.metadata.authorization_servers[0];
assertTrustedIssuer(issuer);
authorizationMetadata = await fetchAuthorizationMetadata(issuer);

Add the test:

test("discovers a pinned authorization server", () => {
  expect(authorizationMetadata.issuer).toBe(
    discovery.metadata.authorization_servers[0]
  );
  expect(authorizationMetadata.authorization_endpoint).toMatch(/^https:\/\//);
  expect(authorizationMetadata.token_endpoint).toMatch(/^https:\/\//);
  expect(authorizationMetadata.code_challenge_methods_supported)
    .toContain("S256");
});

MCP clients must support OAuth Authorization Server Metadata and OpenID Connect Discovery. Pin the exact tenant issuer in automation. Host-only allowlists are insufficient when one identity host serves multiple tenants.

Verification: run npm run test:oauth -- -t "pinned authorization". A failure should identify discovery or issuer trust before a browser opens.

Step 5: Construct S256 PKCE and Resource Parameters

Add these functions:

function base64url(value) {
  return Buffer.from(value).toString("base64url");
}

function createPkce() {
  const verifier = base64url(randomBytes(48));
  const challenge = base64url(
    createHash("sha256").update(verifier, "ascii").digest()
  );
  return { verifier, challenge };
}

function authorizationUrl(metadata, pkce, state) {
  const url = new URL(metadata.authorization_endpoint);
  url.search = new URLSearchParams({
    response_type: "code",
    client_id: config.clientId,
    redirect_uri: config.redirectUri,
    code_challenge: pkce.challenge,
    code_challenge_method: "S256",
    resource: canonicalResource(),
    state,
    scope: "mcp:tools:read"
  }).toString();
  return url;
}

Test the request without needing credentials:

test("builds PKCE authorization with a resource indicator", () => {
  const pkce = createPkce();
  const state = base64url(randomBytes(24));
  const url = authorizationUrl(authorizationMetadata, pkce, state);
  expect(url.searchParams.get("code_challenge_method")).toBe("S256");
  expect(url.searchParams.get("code_challenge")).toBe(pkce.challenge);
  expect(url.searchParams.get("resource")).toBe(canonicalResource());
  expect(url.searchParams.get("state")).toBe(state);
  expect(pkce.verifier.length).toBeGreaterThanOrEqual(43);
  expect(url.searchParams.has("client_secret")).toBe(false);
});

PKCE binds code redemption to the initiating client instance. State correlates the callback with the browser transaction. They address different attacks, so one does not replace the other. Public MCP clients must not depend on an embedded secret that anyone can extract.

Verification: run npm run test:oauth -- -t "PKCE authorization". Confirm S256, resource, and state assertions pass.

Step 6: Exchange a Disposable Authorization Code

Add the exchange helper:

async function exchangeCode({ code, verifier, resource = canonicalResource() }) {
  const response = await fetch(authorizationMetadata.token_endpoint, {
    method: "POST",
    headers: { "content-type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      client_id: config.clientId,
      redirect_uri: config.redirectUri,
      code,
      code_verifier: verifier,
      resource
    })
  });
  return {
    response,
    payload: await response.json().catch(() => ({}))
  };
}

test.skipIf(!config.authorizationCode || !config.codeVerifier)(
  "exchanges a test code with verifier and resource",
  async () => {
    const { response, payload } = await exchangeCode({
      code: config.authorizationCode,
      verifier: config.codeVerifier
    });
    expect(response.status).toBe(200);
    expect(payload.token_type.toLowerCase()).toBe("bearer");
    expect(payload.access_token).toEqual(expect.any(String));
    expect(payload.expires_in).toBeGreaterThan(0);
  }
);

Obtain a code manually with a disposable account or automate the browser only through an approved test identity. Never automate a production user's password. Codes are single-use, so issue separate codes for positive exchange, wrong verifier, changed redirect URI, wrong resource, and replay cases. A wrong verifier or replay should return HTTP 400 with invalid_grant.

Verification: export a fresh code and matching verifier, then run npm run test:oauth -- -t "exchanges a test code". Remove both shell variables immediately afterward.

Step 7: Verify the Access Token Cryptographically

Add JWT verification:

async function verifyAccessToken(token) {
  if (!authorizationMetadata.jwks_uri) {
    throw new Error("No jwks_uri; test RFC 7662 introspection instead");
  }
  const keys = createRemoteJWKSet(
    new URL(authorizationMetadata.jwks_uri)
  );
  return jwtVerify(token, keys, {
    issuer: authorizationMetadata.issuer,
    audience: canonicalResource(),
    clockTolerance: 5
  });
}

test.skipIf(!config.accessToken)(
  "verifies signature, issuer, audience, and time",
  async () => {
    const verified = await verifyAccessToken(config.accessToken);
    expect(verified.payload.aud).toBeDefined();
    expect(verified.payload.exp).toBeGreaterThan(
      Math.floor(Date.now() / 1000)
    );
  }
);

test.skipIf(!config.accessToken)(
  "enforces the test environment lifetime policy",
  () => {
    const claims = decodeJwt(config.accessToken);
    expect(claims.exp - claims.iat).toBeLessThanOrEqual(3600);
  }
);

The 3,600-second ceiling is an illustrative local policy, not an MCP protocol requirement. Adjust it to your threat model. Decoding a JWT is useful for the lifetime policy but does not verify authenticity. jwtVerify checks signature and claims against trusted keys. For opaque tokens, call RFC 7662 introspection with a confidential test client and assert active, expiry, client, scope, and audience.

Verification: run the signature test with a short-lived staging token. A token minted for another API must fail with an audience claim error.

Step 8: Exercise MCP Authorization Decisions

Add a reusable MCP caller:

async function mcpRequest(token, method, params = {}) {
  return fetch(config.mcpUrl, {
    method: "POST",
    headers: {
      "content-type": "application/json",
      "accept": "application/json, text/event-stream",
      ...(token ? { authorization: `Bearer ${token}` } : {})
    },
    body: JSON.stringify({
      jsonrpc: "2.0",
      id: crypto.randomUUID(),
      method,
      params
    })
  });
}

const initializeParams = {
  protocolVersion: "2025-11-25",
  capabilities: {},
  clientInfo: { name: "oauth-test", version: "1.0.0" }
};

test.skipIf(!config.accessToken)(
  "initializes MCP with an authorized token",
  async () => {
    const response = await mcpRequest(
      config.accessToken, "initialize", initializeParams
    );
    expect(response.status).toBe(200);
    expect(await response.text()).toContain('"result"');
  }
);

test("rejects malformed bearer credentials", async () => {
  const response = await mcpRequest(
    "not-a-jwt", "initialize", initializeParams
  );
  expect(response.status).toBe(401);
  expect(await response.text()).not.toContain('"result"');
});

Add fixture-issued tokens for wrong audience, expired time, future nbf, unknown issuer, corrupt signature, and missing scope. Expect 401 for invalid credentials. Expect 403 when a valid credential lacks permission. Initialize first, preserve a returned Mcp-Session-Id when required, send notifications/initialized, and then test tools/list and sensitive tools/call operations separately. A session ID is routing state, never proof of identity.

Verification: run npm run test:oauth -- -t "malformed bearer". Expect 401 and no result. Then run the authorized test with a staging token.

Step 9: Run the Suite Safely in CI

Use discovery tests on every pull request and credential-bearing checks only in a protected environment:

name: MCP OAuth checks
on:
  pull_request:
  workflow_dispatch:
jobs:
  oauth-contract:
    runs-on: ubuntu-24.04
    timeout-minutes: 10
    permissions:
      contents: read
    env:
      MCP_URL: https://mcp.staging.example.com/mcp
      OAUTH_ISSUER: https://login.staging.example.com/
      OAUTH_CLIENT_ID: mcp-oauth-test-client
      MCP_ACCESS_TOKEN: ${{ secrets.MCP_STAGING_ACCESS_TOKEN }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22.18.0
          cache: npm
      - run: npm ci
      - run: npm run test:oauth

Prefer workload identity or a CI token broker over a stored bearer token. If storage is unavoidable, use a short-lived, minimally scoped staging token and prevent untrusted fork jobs from receiving it. Never print tokens, codes, verifiers, cookies, or complete claims. Audit logs should record a correlation ID, subject, issuer decision, audience decision, scope decision, MCP method, and outcome without recording credentials.

Verification: inspect the workflow log. Code-exchange tests should show as skipped unless dedicated ephemeral inputs exist, token tests should run only with the protected secret, and no JWT-shaped string should appear.

Test MCP Server OAuth Authorization With an Attack Matrix

A positive flow establishes interoperability. Negative cases establish enforcement. Create each fixture independently so one changed property explains the outcome. Tokens that are simultaneously expired, wrongly scoped, and signed by an unknown key produce ambiguous failures.

Case Mutation Required result
No token omit the Authorization header 401 and no protected JSON-RPC result
Wrong scheme send Basic credentials to the bearer-protected endpoint 401 with no processing
Malformed token send a non-JWT string where JWTs are expected 401 without parser details
Corrupt signature flip one byte in an otherwise valid JWT 401 after cryptographic verification
Unknown issuer use a valid token from an untrusted tenant 401
Wrong audience mint a token for an unrelated API 401
Expired token set exp before the server clock 401
Future token set nbf beyond allowed clock tolerance 401
Missing read scope remove mcp:tools:read from a valid token 403
Missing write scope call a mutating tool with read-only scope 403 and no side effect
Excess scope request ask the issuer for an administrative scope denied consent or policy-controlled issuance
Wrong verifier redeem a fresh code with another PKCE verifier 400 invalid_grant
Code replay redeem the same code twice the second exchange returns invalid_grant
Changed redirect URI alter the URI during token exchange 400
Wrong resource exchange replace the MCP resource in the token request error or a token for only the alternate audience
Untrusted Origin send a browser Origin outside the allowlist 403
Session swap reuse a session ID created by another subject denial or a separately authorized session
Revoked grant revoke the test grant and reuse its token denial according to documented revocation policy

No token

For this case, omit the Authorization header. Require 401 and no protected JSON-RPC result. If it succeeds, confidential data is reachable before authentication. Preserve the response status, challenge fields, correlation identifier, and matching sanitized audit event. Keep every other issuer, key, audience, time, scope, request body, and MCP session property identical to the positive fixture so the failed control is unmistakable.

Wrong scheme

For this case, send Basic credentials to the bearer-protected endpoint. Require 401 with no processing. If it succeeds, the server confuses authentication mechanisms. Preserve the response status, challenge fields, correlation identifier, and matching sanitized audit event. Keep every other issuer, key, audience, time, scope, request body, and MCP session property identical to the positive fixture so the failed control is unmistakable.

Malformed token

For this case, send a non-JWT string where JWTs are expected. Require 401 without parser details. If it succeeds, error handling leaks internals or accepts garbage. Preserve the response status, challenge fields, correlation identifier, and matching sanitized audit event. Keep every other issuer, key, audience, time, scope, request body, and MCP session property identical to the positive fixture so the failed control is unmistakable.

Corrupt signature

For this case, flip one byte in an otherwise valid JWT. Require 401 after cryptographic verification. If it succeeds, claims are trusted without signature integrity. Preserve the response status, challenge fields, correlation identifier, and matching sanitized audit event. Keep every other issuer, key, audience, time, scope, request body, and MCP session property identical to the positive fixture so the failed control is unmistakable.

Unknown issuer

For this case, use a valid token from an untrusted tenant. Require 401. If it succeeds, issuer pinning is absent. Preserve the response status, challenge fields, correlation identifier, and matching sanitized audit event. Keep every other issuer, key, audience, time, scope, request body, and MCP session property identical to the positive fixture so the failed control is unmistakable.

Wrong audience

For this case, mint a token for an unrelated API. Require 401. If it succeeds, token substitution or confused-deputy behavior is possible. Preserve the response status, challenge fields, correlation identifier, and matching sanitized audit event. Keep every other issuer, key, audience, time, scope, request body, and MCP session property identical to the positive fixture so the failed control is unmistakable.

Expired token

For this case, set exp before the server clock. Require 401. If it succeeds, stolen credentials remain usable beyond their lifetime. Preserve the response status, challenge fields, correlation identifier, and matching sanitized audit event. Keep every other issuer, key, audience, time, scope, request body, and MCP session property identical to the positive fixture so the failed control is unmistakable.

Future token

For this case, set nbf beyond allowed clock tolerance. Require 401. If it succeeds, tokens become active before their intended time. Preserve the response status, challenge fields, correlation identifier, and matching sanitized audit event. Keep every other issuer, key, audience, time, scope, request body, and MCP session property identical to the positive fixture so the failed control is unmistakable.

Missing read scope

For this case, remove mcp:tools:read from a valid token. Require 403. If it succeeds, method authorization is not enforcing least privilege. Preserve the response status, challenge fields, correlation identifier, and matching sanitized audit event. Keep every other issuer, key, audience, time, scope, request body, and MCP session property identical to the positive fixture so the failed control is unmistakable.

Missing write scope

For this case, call a mutating tool with read-only scope. Require 403 and no side effect. If it succeeds, read credentials can change protected state. Preserve the response status, challenge fields, correlation identifier, and matching sanitized audit event. Keep every other issuer, key, audience, time, scope, request body, and MCP session property identical to the positive fixture so the failed control is unmistakable.

Excess scope request

For this case, ask the issuer for an administrative scope. Require denied consent or policy-controlled issuance. If it succeeds, clients can escalate themselves. Preserve the response status, challenge fields, correlation identifier, and matching sanitized audit event. Keep every other issuer, key, audience, time, scope, request body, and MCP session property identical to the positive fixture so the failed control is unmistakable.

Wrong verifier

For this case, redeem a fresh code with another PKCE verifier. Require 400 invalid_grant. If it succeeds, authorization-code interception is exploitable. Preserve the response status, challenge fields, correlation identifier, and matching sanitized audit event. Keep every other issuer, key, audience, time, scope, request body, and MCP session property identical to the positive fixture so the failed control is unmistakable.

Code replay

For this case, redeem the same code twice. Require the second exchange returns invalid_grant. If it succeeds, authorization codes are reusable. Preserve the response status, challenge fields, correlation identifier, and matching sanitized audit event. Keep every other issuer, key, audience, time, scope, request body, and MCP session property identical to the positive fixture so the failed control is unmistakable.

Changed redirect URI

For this case, alter the URI during token exchange. Require 400. If it succeeds, the code is not bound to its registered callback. Preserve the response status, challenge fields, correlation identifier, and matching sanitized audit event. Keep every other issuer, key, audience, time, scope, request body, and MCP session property identical to the positive fixture so the failed control is unmistakable.

Wrong resource exchange

For this case, replace the MCP resource in the token request. Require error or a token for only the alternate audience. If it succeeds, resource binding can be bypassed. Preserve the response status, challenge fields, correlation identifier, and matching sanitized audit event. Keep every other issuer, key, audience, time, scope, request body, and MCP session property identical to the positive fixture so the failed control is unmistakable.

Untrusted Origin

For this case, send a browser Origin outside the allowlist. Require 403. If it succeeds, DNS rebinding or cross-origin access may reach local MCP services. Preserve the response status, challenge fields, correlation identifier, and matching sanitized audit event. Keep every other issuer, key, audience, time, scope, request body, and MCP session property identical to the positive fixture so the failed control is unmistakable.

Session swap

For this case, reuse a session ID created by another subject. Require denial or a separately authorized session. If it succeeds, session state is being treated as identity. Preserve the response status, challenge fields, correlation identifier, and matching sanitized audit event. Keep every other issuer, key, audience, time, scope, request body, and MCP session property identical to the positive fixture so the failed control is unmistakable.

Revoked grant

For this case, revoke the test grant and reuse its token. Require denial according to documented revocation policy. If it succeeds, revocation has no operational effect. Preserve the response status, challenge fields, correlation identifier, and matching sanitized audit event. Keep every other issuer, key, audience, time, scope, request body, and MCP session property identical to the positive fixture so the failed control is unmistakable.

Troubleshooting

Problem: protected resource metadata returns 404 -> derive the well-known URL from the full resource path. Confirm the proxy does not strip /.well-known and that path-specific metadata is deployed.

Problem: issuer values differ only by a trailing slash -> make configuration exactly match published metadata. Issuer identifiers are exact strings, so do not normalize the assertion.

Problem: authorization rejects the resource parameter -> register the exact MCP resource with the issuer and send the identical value in authorization and token requests. Do not use the issuer URL or a generic audience such as api.

Problem: exchange returns invalid_grant -> obtain a fresh code, use its matching verifier, and compare the redirect URI byte for byte. A previous failed redemption might already have consumed the code.

Problem: a seemingly valid token returns 401 -> check signature algorithm, key ID, issuer, audience, expiry, and not-before. Refresh cached JWKS after legitimate key rotation, but never accept an unknown algorithm to silence the failure.

Problem: MCP returns 406 or the request hangs -> send Accept: application/json, text/event-stream, parse the actual response content type, and preserve the server-issued session ID after initialization.

Best Practices

  • Keep discovery, authentication, token validation, and permission assertions independent.
  • Require S256 PKCE and unpredictable state for public clients.
  • Send the exact resource indicator in both authorization and token requests.
  • Verify signatures through JWKS or introspection, never through decoding alone.
  • Check issuer and audience in addition to expiry and scope.
  • Give CI identities harmless tools and the least possible scopes.
  • Treat Mcp-Session-Id as untrusted state.
  • Test invalid browser Origin handling on Streamable HTTP.
  • Redact credentials and sensitive claims from reports.
  • Change one token property per negative fixture.

Interview Questions and Answers

The structured interview questions below cover discovery, resource binding, PKCE, token validation, status codes, negative testing, and token passthrough. A strong answer connects each OAuth control to the MCP resource boundary instead of describing login screens.

Where To Go Next

Extend the baseline with a controlled token factory and run the attack matrix against each sensitive tool. Continue with JWT authentication testing, API test case design, and fuzzing MCP tool arguments. Prepare for interviews with MCP testing questions for QA engineers, then rehearse authorization failures in /practice.

Conclusion

To test MCP server OAuth authorization well, validate the entire chain: unauthenticated denial, protected resource metadata, pinned issuer discovery, S256 PKCE, resource-bound exchange, cryptographic checks, scopes, and MCP method decisions. Carefully constructed denials are essential because they prove the server protects data when one trust condition is absent.

Start with checks that need no secrets. Add short-lived staging identities and single-defect negative tokens next. This produces diagnostic failures while keeping the test system safer than the feature it evaluates.

Interview Questions and Answers

Explain MCP OAuth authorization discovery.

The client contacts the MCP resource and obtains or derives RFC 9728 protected resource metadata. That document identifies authorization-server issuers. The client retrieves OAuth metadata or OpenID Connect discovery before starting Authorization Code with PKCE.

Why must an MCP client send the resource parameter?

It names the MCP server for which the token is requested. The issuer can bind the token to that audience, and the resource server can reject tokens minted for unrelated APIs. This reduces token substitution risk.

What belongs in a protected resource metadata test?

Assert HTTP 200, JSON content, exact resource identity, and at least one trusted HTTPS authorization-server issuer. If a challenge supplied the metadata URL, still verify that the document identifies the originally contacted MCP resource.

Why are PKCE and state both necessary?

PKCE binds code redemption to the client instance possessing the verifier. State correlates the callback with the initiating browser transaction. They cover different attack paths.

How should an MCP server validate a JWT?

Verify its signature using trusted issuer keys and enforce algorithm, issuer, audience, expiry, and not-before. Then evaluate scope or other authorization claims for the requested MCP method. Decoding alone is not validation.

Which negative OAuth cases provide the most value?

Test corrupt signature, wrong issuer, wrong audience, expired time, future not-before, and missing scope independently. Also cover code replay, wrong verifier, redirect mismatch, malformed bearer syntax, untrusted Origin, and session swapping.

Why must an MCP server avoid token passthrough?

The client token targets the MCP server, not an upstream API. Forwarding it can create a confused deputy and expose credentials to the wrong audience. Obtain a separate upstream token for the upstream resource.

Frequently Asked Questions

How do I test MCP server OAuth authorization?

Probe initialize without credentials, validate RFC 9728 metadata, and pin the issuer. Then exercise PKCE and resource-bound exchange, verify token claims cryptographically, and call MCP methods with valid, invalid, and under-scoped tokens.

Does an MCP server have to return resource_metadata in WWW-Authenticate?

No. Current MCP discovery permits fallback to the RFC 9728 well-known URL when that optional challenge parameter is absent. Test both challenge-directed discovery and deterministic fallback.

What resource value should an MCP OAuth test use?

Use the canonical and normally most specific URI of the MCP endpoint, such as https://mcp.example.com/mcp. Send the identical value during authorization and exchange, then require the token audience to authorize it.

How do I test PKCE failure?

Issue a dedicated code with an S256 challenge, then exchange it using another verifier. Expect HTTP 400 with invalid_grant, and do not reuse that code for a positive test.

Should a missing MCP OAuth scope return 401 or 403?

A valid trusted token lacking permission should normally produce 403. Missing, expired, wrongly signed, wrongly issued, or wrong-audience credentials should produce 401.

Can CI store an MCP bearer token?

A short-lived, minimally scoped staging token can be a protected secret when no token broker exists. Block fork access, never print it, and rotate it frequently.

How do I test opaque MCP access tokens?

Use the issuer's RFC 7662 introspection endpoint with confidential test-client authentication. Assert active status, client, scope, expiry, and resource or audience rather than attempting JWT verification.

Related Guides