Skip to content
tutorial

MCP OAuth 2.1 with PKCE: secure your agent server in 7 steps

| 9 min read
Padlock representing OAuth 2.1 authorization for MCP servers
Photo by FLY:D on Unsplash
Padlock on a door frame representing OAuth 2.1 authorization for MCP servers
OAuth 2.1 with PKCE turns your MCP server from a bearer-token honeypot into a scoped, revocable, auditable resource. Photo by FLY:D on Unsplash

The 2026-03-15 MCP specification locks OAuth 2.1 with PKCE as the authorization standard for remote MCP servers. API keys in environment variables still work on day one, but the registry, Claude Desktop, Cursor, VS Code Copilot, and Windsurf all prefer OAuth-backed servers when users browse for integrations. If your server still asks for a long-lived bearer in a config file, you are leaving a chunk of distribution on the table.

The hard part is not OAuth itself; it is the five small specs the MCP auth flow composes together: OAuth 2.1 (RFC 9700), PKCE (RFC 7636), Dynamic Client Registration (RFC 7591), Resource Indicators (RFC 8707), and Protected Resource Metadata. Each one is small; the wiring is where teams get stuck. Here is the path, in order.

Step 1: Serve a Protected Resource Metadata document

The PRM file tells clients where your authorization server is, which scopes you accept, and what resource identifier to put in the aud claim. Host it at /.well-known/oauth-protected-resource on the same origin as your MCP endpoint:

{
  "resource": "https://api.acme.com/mcp",
  "authorization_servers": ["https://auth.acme.com"],
  "scopes_supported": [
    "tools:read",
    "tools:invoke:safe",
    "tools:invoke:destructive",
    "resources:read"
  ],
  "bearer_methods_supported": ["header"],
  "resource_documentation": "https://docs.acme.com/mcp",
  "resource_signing_alg_values_supported": ["RS256", "ES256"]
}

The authorization_servers list is the discovery hop. Clients fetch your PRM, follow the first entry to the AS metadata document, and build the authorization URL from there. Keep the list to one entry unless you run a federation; multiple issuers confuse the client and nothing in the spec requires it.

Step 2: Publish the authorization server metadata

Your identity provider (Auth0, Okta, Clerk, Cloudflare Access, or a self-hosted Hydra) serves the AS metadata at /.well-known/oauth-authorization-server. MCP clients consume it to learn the authorize and token endpoints, the supported grant types, and the JWKS location for token verification:

{
  "issuer": "https://auth.acme.com",
  "authorization_endpoint": "https://auth.acme.com/oauth/authorize",
  "token_endpoint": "https://auth.acme.com/oauth/token",
  "registration_endpoint": "https://auth.acme.com/oauth/register",
  "jwks_uri": "https://auth.acme.com/.well-known/jwks.json",
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint_auth_methods_supported": ["none", "client_secret_basic"],
  "scopes_supported": [
    "tools:read",
    "tools:invoke:safe",
    "tools:invoke:destructive",
    "resources:read"
  ]
}

Three fields the spec demands: code_challenge_methods_supported must include S256; grant_types_supported must include authorization_code and refresh_token; registration_endpoint must be present if you support dynamic clients. Miss any one and Claude Desktop will flag the server as non-compliant during setup.

Step 3: Generate a PKCE pair on the client

PKCE is 20 lines of client code and it kills the authorization-code-interception class of attack. Generate 32 random bytes, base64url-encode them into a verifier, SHA-256 the verifier into a challenge, and send the challenge with the authorize request:

// Client side: generate PKCE pair before launching the OAuth flow
function base64url(bytes) {
  return btoa(String.fromCharCode(...bytes))
    .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

async function generatePkce() {
  const randomBytes = crypto.getRandomValues(new Uint8Array(32));
  const verifier = base64url(randomBytes);
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
  const challenge = base64url(new Uint8Array(digest));
  return { verifier, challenge };
}

const { verifier, challenge } = await generatePkce();
sessionStorage.setItem("pkce_verifier", verifier);

const authUrl = new URL("https://auth.acme.com/oauth/authorize");
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("client_id", CLIENT_ID);
authUrl.searchParams.set("redirect_uri", REDIRECT_URI);
authUrl.searchParams.set("scope", "tools:read tools:invoke:safe");
authUrl.searchParams.set("code_challenge", challenge);
authUrl.searchParams.set("code_challenge_method", "S256");
authUrl.searchParams.set("resource", "https://api.acme.com/mcp"); // RFC 8707
window.location.href = authUrl.toString();

The resource parameter (RFC 8707) is the one most implementations miss. Without it, a token minted for your MCP server can be replayed against any other API that trusts the same issuer; with it, the aud claim pins the token to your resource identifier and nothing else accepts it.

Step 4: Exchange the code for tokens

After the redirect back, the client POSTs the code plus the original verifier to the token endpoint. The authorization server recomputes the SHA-256 of the verifier and compares it to the stored challenge; if they match, you get an access token:

// After redirect back: exchange code for tokens with the verifier
async function exchangeCode(code) {
  const verifier = sessionStorage.getItem("pkce_verifier");
  const body = new URLSearchParams({
    grant_type: "authorization_code",
    code,
    redirect_uri: REDIRECT_URI,
    client_id: CLIENT_ID,
    code_verifier: verifier,
    resource: "https://api.acme.com/mcp",
  });

  const res = await fetch("https://auth.acme.com/oauth/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body,
  });

  if (!res.ok) throw new Error(`token exchange failed: ${await res.text()}`);
  const { access_token, refresh_token, expires_in } = await res.json();
  sessionStorage.removeItem("pkce_verifier");
  return { access_token, refresh_token, expires_in };
}

Store the refresh token in secure storage (Keychain, Windows Credential Manager, Linux libsecret) and drop the verifier from session storage the moment the exchange succeeds. Access tokens live 5 to 60 minutes; refresh tokens live longer but should still rotate on use.

Step 5: Verify the bearer token on every MCP request

Streamable HTTP makes token verification simple: each HTTP request carries the Authorization header, so your MCP server verifies it in middleware before dispatching the tool call. Fetch the JWKS once, cache it, and validate issuer, audience, and expiry on every call:

// MCP server middleware: validate the bearer token before every tool call
import { createRemoteJWKSet, jwtVerify } from "jose";

const JWKS = createRemoteJWKSet(new URL("https://auth.acme.com/.well-known/jwks.json"));
const EXPECTED_ISSUER = "https://auth.acme.com";
const EXPECTED_AUDIENCE = "https://api.acme.com/mcp";

async function authenticate(req) {
  const header = req.headers.get("authorization") ?? "";
  if (!header.startsWith("Bearer ")) return { ok: false, reason: "missing bearer" };
  const token = header.slice(7);

  try {
    const { payload } = await jwtVerify(token, JWKS, {
      issuer: EXPECTED_ISSUER,
      audience: EXPECTED_AUDIENCE,
      algorithms: ["RS256", "ES256"],
    });

    return {
      ok: true,
      subject: payload.sub,
      scopes: (payload.scope ?? "").split(" "),
      clientId: payload.client_id,
    };
  } catch (err) {
    return { ok: false, reason: err.message };
  }
}

The three claims that catch real bugs: iss pins the issuer; aud pins the resource (prevents cross-resource replay); exp catches stale tokens. Do not accept tokens without these; "I validated the signature" is not the same as "I validated the claims."

Step 6: Gate each tool on the scopes its annotations declare

OAuth 2.1 minus scope design gives you binary access: either the client can call every tool or none. The MCP annotation system closes that gap by letting each tool declare the scopes it needs. The handler reads the annotations and the token scopes at invocation time:

// Gate each MCP tool on the scopes its annotations declare
server.tool(
  "send_email",
  {
    args: z.object({ to: z.string(), subject: z.string(), body: z.string() }),
    annotations: {
      requiredScopes: ["tools:invoke:destructive"],
      destructive: true,
      idempotent: false,
    },
  },
  async ({ args }, ctx) => {
    const authz = ctx.request.authz;
    const needed = ctx.tool.annotations.requiredScopes;
    if (!needed.every((s) => authz.scopes.includes(s))) {
      throw new McpError(-32003, "insufficient scope");
    }
    const result = await transport.send(args);
    return { content: [{ type: "text", text: `sent ${result.id}` }] };
  },
);

Keep destructive and read-only scopes separate. A user who granted tools:read to run a weekly report should not be able to run send_email from the same token. Claude Desktop and Cursor surface requested scopes in the consent screen, so the split makes the UX honest about what the client can do.

Step 7: Support dynamic client registration

Dynamic client registration lets an MCP client self-register at your authorization server without a human filling out a form. Cursor or Claude Desktop POSTs a registration request, receives a client_id, and runs the OAuth flow immediately:

# Dynamic client registration: a client self-registers in one call
curl -X POST https://auth.acme.com/oauth/register \
  -H "Content-Type: application/json" \
  -d '{
    "client_name": "Claude Desktop",
    "redirect_uris": ["http://localhost:6789/callback"],
    "token_endpoint_auth_method": "none",
    "grant_types": ["authorization_code", "refresh_token"],
    "response_types": ["code"],
    "scope": "tools:read tools:invoke:safe"
  }'

# Response includes the assigned client_id
{
  "client_id": "cli_01HX9Y2K3F4G5H6J",
  "client_id_issued_at": 1744748400,
  "registration_access_token": "reg_...",
  "registration_client_uri": "https://auth.acme.com/oauth/register/cli_01HX9Y2K3F4G5H6J"
}

This is the piece that moves your MCP server from "paste an API key in a config file" to "click Connect in Cursor and grant access." Worth the work if your server is public; skippable if you only ship to a small set of known clients.

Verify the full flow end-to-end

Once the pieces are in place, a full MCP tool call looks like any other bearer-authenticated HTTP request:

curl -X POST https://api.acme.com/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "tools/call",
    "params": {
      "name": "ip_lookup",
      "arguments": { "ip": "1.1.1.1" }
    }
  }'

If you see 401 with WWW-Authenticate: Bearer resource_metadata="...", the client knows where to discover your auth setup and re-run the flow. That header is the handshake that makes silent token refresh and reconnect possible after a revocation.

Put the PRM URL in the WWW-Authenticate header on every 401 response. The MCP spec makes this the canonical discovery hint; clients that see an unannotated 401 fall back to asking the user for an API key, which is exactly the flow OAuth 2.1 is trying to replace.

Scope design cheat sheet

Scope Covers Default grant
tools:read List available tools, read descriptions Always granted on consent
tools:invoke:safe Idempotent, read-only tool calls (lookups, parsing) Granted per user consent
tools:invoke:destructive Mutating tool calls (send email, create order) Requires explicit per-session consent
resources:read Read-only access to exposed resources Granted per resource pattern
prompts:read List and read server-defined prompts Granted on consent

Key takeaways

  • OAuth 2.1 requires PKCE. Not a nice-to-have; every auth code flow carries a verifier and challenge or it fails spec compliance.
  • PRM at /.well-known/oauth-protected-resource is the entry point. Clients discover your auth setup from that one URL; skip it and clients cannot auto-configure.
  • Use the resource parameter (RFC 8707). Pins the token audience to your MCP server so a leaked token cannot replay against other APIs.
  • Validate issuer, audience, expiry, scope. Signature checks alone let cross-resource replay through.
  • Split destructive scopes. tools:read, tools:invoke:safe, and tools:invoke:destructive give users the consent UX they expect from OS permissions.
  • Dynamic client registration unlocks zero-config installs. Pay the implementation cost once; every new MCP client benefits forever.

Botoi's hosted MCP server at api.botoi.com/mcp runs the same pattern. Browse the MCP setup docs for Claude Desktop, Claude Code, Cursor, VS Code, and Windsurf configs. For the JWT signing and verification primitives the middleware above uses, see the JWT API endpoints in the interactive docs.

Frequently asked questions

Why PKCE for MCP when agents are not browsers?
PKCE (Proof Key for Code Exchange) binds the authorization code to a one-time cryptographic secret that only the initiating client knows. In the MCP context the attacker is not a malicious browser extension; it is a rogue agent or a stolen authorization code floating in a terminal log. PKCE means an intercepted code cannot be redeemed without the original code verifier. OAuth 2.1 requires PKCE for every authorization code flow, including confidential clients, so MCP just follows the spec.
What is a Protected Resource Metadata document?
A PRM document is a JSON file served at /.well-known/oauth-protected-resource that tells OAuth clients where the authorization server lives, which scopes the resource accepts, and what resource identifier to put in the aud claim. The MCP authorization spec added PRM so an MCP client discovers your auth setup without out-of-band configuration. The client fetches the PRM, follows the authorization_servers URL to the AS metadata, runs the OAuth dance, and arrives at your MCP server with a valid bearer token.
Do I need dynamic client registration?
For public MCP servers exposed to many clients, yes. Dynamic client registration (RFC 7591) lets an MCP client self-register at your authorization server, receive a client_id, and start the OAuth flow in one round trip. Without it, every user has to manually create an app in your dashboard before they can connect Claude or Cursor. For internal or allowlisted integrations, a pre-provisioned client_id still works.
Where does the bearer token live during the session?
In the Authorization header of every MCP request. Streamable HTTP makes this clean; each request carries the token, and your server verifies it in middleware before dispatching the tool call. Short-lived access tokens (5 to 60 minutes) with refresh tokens held by the client minimize blast radius from a leaked log line. Never embed the token in the URL; logs and proxies capture URLs routinely.
How do MCP tool scopes compare to OAuth scopes?
MCP leans on role-based authorization: a token carries scopes like tools:read, tools:invoke:safe, or tools:invoke:destructive, and individual tool annotations declare which scopes they require. Scopes map one-to-one to OAuth scopes, so you can use the same scope design you already have for a REST API. The new piece is tool-level annotations on the MCP side; they make the authorization decision visible in the tool registry, not just in the handler code.

Try this API

JWT Decode API — interactive playground and code examples

More tutorial posts

Start building with botoi

150+ API endpoints for lookup, text processing, image generation, and developer utilities. Free tier, no credit card.