MCP OAuth 2.1 with PKCE: secure your agent server in 7 steps
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-resourceis the entry point. Clients discover your auth setup from that one URL; skip it and clients cannot auto-configure. - Use the
resourceparameter (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, andtools:invoke:destructivegive 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.