MCP Authorization and OAuth
Optional until you opt in, then very specific. Why the resource parameter is mandatory even when the server ignores it, why PKCE doesn't stop mix-up attacks, and why Dynamic Client Registration just got demoted.
Authorization is optional in MCP, and then extremely specific the moment you opt in. If your server speaks HTTP and holds anything worth protecting, the specification hands you a stack of RFCs and a small number of hard requirements — several of which are not what an experienced OAuth implementer would guess.
Worth stating first, because it saves people building the wrong thing: stdio servers should not do any of this. The spec says implementations using stdio SHOULD NOT follow the authorization specification and should retrieve credentials from the environment instead. A local subprocess launched by your own client does not need an OAuth dance with itself.
Everything below is about HTTP transports.
Who plays which role
Three parties, mapped onto OAuth 2.1:
- The MCP server is a resource server. It accepts access tokens and serves protected requests.
- The MCP client is an OAuth client, acting for a resource owner.
- The authorization server issues the tokens. It may be the same deployment as the resource server or a completely separate entity — the spec deliberately does not care.
That separation is the point. An MCP server does not have to become an identity provider; it has to be able to say where its identity provider lives.
The flow, in the order it actually happens
- The client makes a request with no token.
- The server returns 401 with a
WWW-Authenticateheader carrying aresource_metadataURL and, ideally, ascopeparameter. - The client fetches the Protected Resource Metadata document and learns which authorization servers are acceptable.
- The client fetches the authorization server's metadata (OAuth 2.0 Authorization Server Metadata or OpenID Connect Discovery — clients MUST support both).
- The client obtains a client ID, generates PKCE parameters, includes a
resourceparameter, records the expected issuer, and opens a browser. - The user authorises. The redirect carries the code and an
issvalue. - The client validates
iss, exchanges the code, and starts sendingAuthorization: Bearer <token>on every request.
Four things in that sequence deserve more than a mention.
Protected Resource Metadata is mandatory
MCP servers MUST implement RFC 9728, and clients MUST use it for authorization server discovery. This is not optional plumbing — it is how a client learns anything at all about how to authenticate to you.
The 401 that starts it looks like this:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
scope="files:read"
If your server returns a bare 401 with no resource_metadata, well-behaved clients have nowhere to go.
The resource parameter is not optional either
Clients MUST implement Resource Indicators (RFC 8707), include the resource parameter in both the authorization request and the token request, and set it to the canonical URI of the MCP server the token is for.
And then the requirement people skip: clients MUST send it whether or not the authorization server supports it.
This is the mechanism that makes token passthrough
detectable. The resource parameter is what lets the authorization server bind an audience into the token, and the audience is what lets your server refuse a token minted for somebody else. The rules are strict about the URI — no fragments, scheme required, most specific form you can manage, and no trailing slash unless it means something:
https://mcp.example.com/mcp— validhttps://mcp.example.com:8443— validmcp.example.com— invalid, no schemehttps://mcp.example.com#fragment— invalid
Dynamic Client Registration has been demoted
If you last read this spec a year ago, this is the biggest change.
Authorization servers and clients SHOULD support Client ID Metadata Documents (CIMD). They MAY support Dynamic Client Registration — which is now deprecated, retained only for backwards compatibility with authorization servers that don't do CIMD.
CIMD replaces register yourself and receive an ID
with your ID *is* an HTTPS URL that serves your metadata.
The authorization server sees a URL-formatted client_id, fetches it, validates the metadata and redirect URIs, and proceeds. No registration endpoint, no per-server registration record.
It also removes one of the preconditions for the confused deputy attack, since there is no longer a dynamic-registration endpoint that an attacker can use to mint a client with an arbitrary redirect URI.
Two new problems arrive with it. The authorization server is now fetching a URL supplied by an unknown party, which is SSRF — the same mitigations apply. And a metadata document proves control of a domain but cannot prove which local process is listening on a localhost redirect URI, so an attacker can present a legitimate client's metadata URL, bind to any local port, and receive the code while the user sees a trustworthy name on the consent screen. Authorization servers are expected to warn on localhost-only redirect URIs and display the redirect hostname prominently.
Both are in MCP security.
Issuer validation, and why PKCE isn't enough
Before redirecting, the client MUST record the issuer from the validated authorization server metadata, stored alongside the PKCE verifier. On the way back it validates the iss in the response against that record.
The attack this stops is the mix-up attack. A client talks to many authorization servers over its life; one of them is hostile and tries to get the client to send it a code issued by an honest server. The spec is explicit that PKCE does not help here, because the client transmits its code_verifier to whichever token endpoint it thinks it should use — which in this attack is the attacker's.
The comparison is strict string comparison per RFC 3986 §6.2.1. No case folding, no default-port elision, no trailing-slash normalisation, no percent-encoding normalisation. And it applies to error responses too: on mismatch the client MUST NOT display error, error_description or error_uri.
Servers SHOULD emit iss today and advertise authorization_response_iss_parameter_supported. The spec signals that a future revision upgrades this to MUST.
Token handling: the three absolutes
MCP servers MUST validate that access tokens were issued specifically
for them as the intended audience.
MCP clients MUST NOT send tokens to the MCP server other than ones
issued by the MCP server's authorization server.
MCP servers MUST NOT accept or transit any other tokens.
Tokens go in the Authorization header on every request — statelessness means there is no session that remembers you — and MUST NOT appear in the query string. Invalid or expired tokens get a 401. Insufficient scope gets a 403.
Step-up authorization
The pattern that makes least privilege workable at runtime.
Ask for very little up front. When a client attempts something its token does not cover, return 403 with a scope challenge:
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope",
scope="files:write",
resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
error_description="File write permission required for this operation"
The client computes the union of its previously requested scopes and the challenged ones, re-authorises, and retries. The union matters: challenge with only files:write and a client that requests exactly that will silently lose the files:read it already had.
Two pieces of judgement the spec calls out. Servers should emit every scope the current operation needs in one challenge — incremental challenges force a round trip each and the user experience collapses. And clients should cap retries and track which scope upgrades were refused, or a denied scope becomes an infinite loop.
Servers must also account for scope hierarchies when deciding sufficiency: if admin implies read, a token holding admin satisfies a read requirement.
What to do about it
Consuming servers? Nothing. This is why the ecosystem standardised on OAuth — Stripe, GitHub, Linear, Notion, Sentry, Supabase, Vercel and the rest all just open a browser. The practical benefit is revocation: an authorised client appears in a dashboard and can be removed, which a pasted API key never can. Check whether the vendor lists sessions; Stripe puts them under OAuth sessions in user settings.
Building a server? Implement RFC 9728, validate the audience on every token, support CIMD, publish minimal scopes_supported, and emit iss. If you proxy a third-party API, per-client consent before you forward is not optional.
Building a client? Send resource always. Record and validate the issuer. Support both discovery mechanisms. Validate URL schemes before opening anything.
If you are writing the server itself, how to build an MCP server covers the protocol side, and MCP transports covers the choice that determines whether any of this applies to you at all.