Securing MCP Servers with OAuth: Authorization in Practice
MCP authorization is the part of the Model Context Protocol spec that most teams get wrong on the first attempt, because it looks like "just add OAuth" and turns out to be a specific profile of OAuth 2.1 with rules most web developers have never had to apply. If you are exposing a remote MCP server to an agent like Claude or any other MCP client, you need three things working together: an authorization server that issues tokens, a resource server (your MCP server) that validates them, and a client that knows how to discover both. This article walks through the actual mechanics: the discovery documents, the PKCE flow, resource indicators, dynamic client registration, and the token validation code you write once and never think about again if you get it right the first time.
This is not a conceptual overview. It assumes you already know what MCP is (tools, resources, prompts exposed over JSON-RPC) and that you are past the "why do I need auth" question. You're here because you're building a remote MCP server, you've read the spec once, and you want the parts that actually matter for implementation.
Why MCP Authorization Is Not Just OAuth
Early MCP authorization (the 2025-03-26 revision of the spec) treated the MCP server as both the authorization server and the resource server, which was fine for a demo and wrong for anything real. The 2025-06-18 revision split those roles apart, and that split is the single most important thing to understand before you write any code.
An MCP server's job is to serve tools and resources. It should not also be in the business of issuing access tokens, managing refresh token rotation, storing user credentials, or running a consent screen. That is Authorization Server (AS) work, and MCP authorization now explicitly delegates it to a separate, standards-compliant OAuth 2.1 authorization server, whether that's Auth0, WorkOS, Okta, Clerk, or a self-hosted server like Ory Hydra.
Your MCP server becomes a pure OAuth 2.0 Resource Server (RS). Its only jobs are:
- Publish metadata telling clients which authorization server issues valid tokens for it
- Reject any request that doesn't carry a valid, correctly-scoped, correctly-audienced access token
- Return the right challenge (
WWW-Authenticateheader) when a request is unauthenticated
This separation is why "MCP authorization" as a phrase now means something closer to "how does an MCP resource server participate correctly in an OAuth 2.1 ecosystem" rather than "how do I implement login." You almost never write token-issuance code yourself.
The MCP Authorization Flow Step by Step
Here is the full sequence an MCP client (an agent host like Claude, or a custom client you write) goes through to get access to a protected remote MCP server. Every step matters; skipping metadata discovery is the most common implementation bug.
- Unauthenticated request. The client calls the MCP server without a token. The server responds
401 Unauthorizedwith aWWW-Authenticateheader pointing to its protected resource metadata document. - Protected Resource Metadata discovery. The client fetches
/.well-known/oauth-protected-resourceon the MCP server. This document (RFC 9728) lists the resource's identifier and which authorization server(s) issue valid tokens for it. - Authorization Server Metadata discovery. The client fetches
/.well-known/oauth-authorization-server(RFC 8414) or the OpenID Connect discovery document from the authorization server named in step 2. This gives the client the authorization endpoint, token endpoint, and supported grant types. - Dynamic Client Registration (optional but common). If the client doesn't already have a
client_idfor this authorization server, it registers itself via RFC 7591's dynamic registration endpoint. This is what lets a general-purpose agent connect to an MCP server it has never seen before, with no manual app-registration step by a human. - Authorization request with PKCE. The client redirects the user to the authorization endpoint with a
code_challenge, requesting the specificresourceparameter identifying this MCP server (more on this below). - User consent. The user authenticates and approves the requested scopes at the authorization server, not at the MCP server.
- Token exchange. The client exchanges the authorization code, plus its
code_verifier, for an access token (and usually a refresh token) at the token endpoint. - Authenticated MCP requests. The client sends the access token as a
Bearertoken on every subsequent MCP request, typically in theAuthorizationheader of the HTTP transport (MCP over Streamable HTTP or SSE). - Resource server validation. The MCP server validates the token: signature, expiry, audience, and scopes, on every request.
The entire flow is standard OAuth 2.1 with one addition that matters more for MCP than almost any other OAuth use case: the resource parameter, which exists specifically to stop token replay across multiple MCP servers.
Resource Indicators: The Detail That Actually Protects You
This is the part of MCP authorization that catches experienced OAuth engineers off guard, because it is easy to implement OAuth correctly in the traditional sense and still leave a real vulnerability open for MCP specifically.
The problem: an agent host often connects to many MCP servers through the same authorization server (think: one identity provider issuing tokens for a company's Slack MCP server, GitHub MCP server, and internal database MCP server). If access tokens issued by that authorization server are not bound to a specific resource, a token minted for the low-stakes "read our public docs" MCP server could be replayed against the high-stakes "run SQL queries" MCP server, if both trust the same authorization server and neither checks the intended audience.
RFC 8707, Resource Indicators for OAuth 2.0, solves this. The MCP spec requires clients to include a resource parameter, set to the canonical URI of the target MCP server, in both the authorization request and the token request:
GET /authorize?
response_type=code
&client_id=abc123
&redirect_uri=https%3A%2F%2Fclient.example.com%2Fcallback
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
&resource=https%3A%2F%2Fmcp.example.com
&scope=mcp%3Atools.read+mcp%3Atools.callThe authorization server records this resource value and, critically, mints an access token whose audience (`aud` claim, if it's a JWT) is scoped to that resource. When your MCP server validates an incoming token, it must check that the aud claim matches its own canonical URI. A token minted for mcp.otherservice.com must be rejected outright, even if the signature is valid and it came from an authorization server you trust.
This is the single check that separates a compliant MCP authorization implementation from a vulnerable one. If your token validation code checks signature and expiry but skips audience, you have built a system where a compromised or overly curious MCP client can pivot a token meant for one server into access on another. Do not skip this.
Separating the Authorization Server from the Resource Server in Practice
If you're building the MCP server itself, you do not implement an authorization server. You point at one. Here's what your MCP server needs to serve, using Node with a minimal HTTP handler (the same logic applies whether you're using Express, Fastify, or the official MCP SDK's transport layer):
// GET /.well-known/oauth-protected-resource
function protectedResourceMetadata(req, res) {
res.json({
resource: "https://mcp.example.com",
authorization_servers: [
"https://auth.example.com"
],
bearer_methods_supported: ["header"],
resource_documentation: "https://example.com/docs/mcp-auth"
});
}That document is the entire contract your MCP server needs to publish. It tells any client: "I am the resource https://mcp.example.com. Tokens for me are minted by https://auth.example.com. Go talk to that server, get me a correctly-audienced token, then come back."
Your MCP server never sees a username or password, never handles a login form, and never issues a token. It only validates. That validation function is where almost all of your MCP authorization code should live:
async function validateMcpToken(authorizationHeader) {
if (!authorizationHeader || !authorizationHeader.startsWith("Bearer ")) {
return { valid: false, status: 401, error: "missing_token" };
}
const token = authorizationHeader.slice(7);
let claims;
try {
claims = await verifyJwt(token, {
issuer: "https://auth.example.com",
jwksUri: "https://auth.example.com/.well-known/jwks.json"
});
} catch (err) {
return { valid: false, status: 401, error: "invalid_token" };
}
// The check that actually matters for MCP: audience binding.
if (claims.aud !== "https://mcp.example.com") {
return { valid: false, status: 403, error: "invalid_audience" };
}
if (claims.exp * 1000 < Date.now()) {
return { valid: false, status: 401, error: "token_expired" };
}
const requiredScopes = ["mcp:tools.call"];
const grantedScopes = (claims.scope || "").split(" ");
const hasRequiredScope = requiredScopes.every(s => grantedScopes.includes(s));
if (!hasRequiredScope) {
return { valid: false, status: 403, error: "insufficient_scope" };
}
return { valid: true, subject: claims.sub, scopes: grantedScopes };
}Wire that into your MCP transport's request handler before any tool call is dispatched. When validation fails with 401, respond with a WWW-Authenticate header that points back at your protected resource metadata, so a well-behaved client can recover and re-authenticate without a human needing to debug it:
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"Dynamic Client Registration: Letting Any Agent Connect Safely
One thing that makes MCP different from a typical enterprise OAuth integration is that the client is often not a single, pre-registered web app; it's a general-purpose agent host that might connect to hundreds of different MCP servers on a user's behalf, each backed by a different authorization server it has never talked to before. Manually registering a client_id for every combination doesn't scale.
Dynamic Client Registration (RFC 7591) solves this. The client posts its metadata to the authorization server's registration endpoint and gets back a client_id (and optionally a client_secret, though public clients using PKCE typically don't need one):
POST /register HTTP/1.1
Host: auth.example.com
Content-Type: application/json
{
"client_name": "My Agent Host",
"redirect_uris": ["https://client.example.com/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
}If you're running the authorization server side (or evaluating one for your MCP deployment), confirm it actually implements dynamic registration. This is the single most common gap in "OAuth-compliant" identity providers that were built for traditional web apps: they assume a human registers an app once through a dashboard, and they either don't expose /register at all or gate it behind an admin token. For MCP authorization to work with arbitrary agent clients, dynamic registration needs to be open (rate-limited, but not manually gated) or you need a fallback manual-registration path your client library can detect and prompt the user through.
Scopes: Design Them Around Tools, Not Around Users
MCP servers expose tools, resources, and prompts, and your scope design should map to that surface directly rather than reusing whatever generic read/write scopes you have lying around from a REST API. A reasonable starting scope set for a typical MCP server:
mcp:tools.list-> can see what tools exist, cannot call themmcp:tools.call-> can invoke toolsmcp:resources.read-> can read exposed resources (files, DB records, docs)mcp:resources.write-> can create or modify resources through tools that mutate statemcp:prompts.read-> can fetch prompt templates the server exposes
If your MCP server exposes destructive tools (delete a record, send an email, execute a payment), give those their own scope rather than folding them into a general tools.call. This lets a user consent to "read my calendar" without also silently granting "send email as me" the first time they connect an agent, and it lets your token validation function do fine-grained checks per tool rather than an all-or-nothing gate at the transport layer.
function requireScope(tool, grantedScopes) {
const scopeMap = {
"list_events": "mcp:calendar.read",
"create_event": "mcp:calendar.write",
"delete_event": "mcp:calendar.delete"
};
const needed = scopeMap[tool];
if (needed && !grantedScopes.includes(needed)) {
throw new McpError(403, `missing scope: ${needed}`);
}
}Call this once per tool invocation, not once per session. A token can be valid and still lack the scope for a specific tool call.
Handling Refresh Tokens and Session Expiry Without Breaking Long-Running Agents
Agents often run for a while: a long research task, a multi-step workflow, an overnight batch job. Access tokens should still be short-lived (15 to 60 minutes is typical), which means your MCP client needs to handle silent refresh, and your MCP server needs to handle a 401 mid-conversation gracefully rather than dropping the whole session.
The pattern that works: when your MCP server rejects a request with 401 invalid_token because the access token expired, a well-implemented client catches that specific error, uses its stored refresh token against the authorization server's token endpoint (no user interaction required), and retries the original MCP request with the new access token. Your MCP server does not need to know anything about refresh tokens; that negotiation happens entirely between the client and the authorization server. Your only job is returning a clean, unambiguous 401 with the WWW-Authenticate header on expiry so the client knows refresh (not a full re-auth) is the right recovery path.
Do not build a custom "session extends automatically" mechanism inside your MCP server to work around this. It reintroduces exactly the kind of ambient trust that resource-indicator binding and short-lived tokens exist to prevent.
Testing Your MCP Authorization Implementation
Before connecting a real agent client, verify each piece independently:
- Metadata discovery. Curl
/.well-known/oauth-protected-resourceon your MCP server and confirm it returns valid JSON with the correctauthorization_serversentry. Do the same for/.well-known/oauth-authorization-serveron your auth server. - Unauthenticated rejection. Send a request to your MCP server with no
Authorizationheader and confirm you get401with a properly formattedWWW-Authenticateheader, not a generic error or a silent pass-through. - Audience rejection. Mint a valid token for a *different* resource URI (or just hand-edit the
audclaim in a test JWT) and confirm your server rejects it with403 invalid_audience. This is the check most implementations skip and it's the one worth testing explicitly. - Expired token rejection. Use a token with
expin the past and confirm you get401 token_expired, not a crash or a silent accept. - Scope enforcement per tool. Call a scoped tool with a token that has read-only scopes and confirm it's rejected at the tool level, not just at the connection level.
- Full flow with a real client. Only after the above pass, run an actual PKCE authorization code flow end to end, ideally with dynamic client registration enabled, and confirm a fresh client with zero prior configuration can discover, register, authorize, and call a tool.
Log every rejection with the specific reason code (invalid_audience, insufficient_scope, token_expired) during development. When a client integration fails, the difference between those four failure modes is usually the entire debugging session.
FAQ
Does every MCP server need OAuth, or is it only for remote servers? Only remote, network-accessible MCP servers need OAuth-based authorization. A local MCP server running over stdio and launched directly by a trusted client (like a CLI tool spawning a subprocess on the same machine) relies on OS-level process isolation instead; there is no network boundary to protect. Once your MCP server is reachable over HTTP by clients you don't control the launch of, MCP authorization applies.
Can I use API keys instead of OAuth for a simple internal MCP server? The MCP spec's authorization section is specifically an OAuth 2.1 profile, and if you want to interoperate with general-purpose MCP clients (including Claude and other agent hosts) out of the box, OAuth is the path they expect. For a fully internal server where you control both the client and server code, a simpler bearer-token scheme can work, but you lose standard discovery, dynamic registration, and the ecosystem tooling built around the OAuth flow. Most teams find it's less work to implement the standard flow once than to maintain a bespoke one.
What's the difference between the MCP server and the authorization server in this model? The MCP server is the OAuth resource server: it validates tokens and serves tools, resources, and prompts. The authorization server is a separate service (often a third-party identity provider) that authenticates users, manages consent, and issues tokens. Your MCP server should never store passwords, run a login screen, or issue its own tokens; that responsibility belongs entirely to the authorization server.
Why does the resource parameter matter if I already validate the token signature? Signature validation only proves the token was issued by an authorization server you trust. It does not prove the token was intended for your specific MCP server. Without checking the aud claim against your server's canonical resource URI, a valid token minted for a different MCP server behind the same authorization server could be replayed against yours. Resource indicators close that gap.
Do I need to implement dynamic client registration myself? Only if you're also running the authorization server. If you're using a third-party identity provider for your MCP deployment, check whether it supports RFC 7591 dynamic registration before committing to it. Many enterprise identity providers built for traditional web app SSO don't expose an open registration endpoint by default, and that gap becomes a real blocker when a general-purpose MCP client tries to connect without prior manual setup.
How do I handle a token that's valid but doesn't have the scope for a specific tool? Reject the individual tool call with a 403 and a clear error naming the missing scope, rather than rejecting the entire MCP session. A single access token often carries multiple scopes covering different tools; a client calling a tool it lacks permission for should get a scoped, recoverable error so it can prompt the user for additional consent if needed, without tearing down tools it does have access to.
Is MCP authorization the same across every version of the spec? No. The 2025-03-26 version of the spec had the MCP server acting as its own authorization server, which is now considered outdated. The 2025-06-18 revision introduced the clean split between authorization server and resource server, added Protected Resource Metadata (RFC 9728), and formalized the resource parameter requirement from RFC 8707. If you're reading older blog posts or sample code about MCP authorization, check which spec revision they target before copying patterns, since the resource server split is the difference that matters most for security.
BootcampA 30-day guided bootcamp: build, harden and ship a production autonomous agent from scratch.
AI AgentsUnderstand how AI agents really work: the loop, the tools, the memory, and why most agent projects fail.