How MCP Authorization Actually Works (and the Ways Teams Get It Wrong)
Model Context Protocol authorization is, underneath the new vocabulary, ordinary OAuth 2.1 — with one rule almost everyone breaks: the server must check who the token was issued for, and reject it otherwise. If you read nothing else here, read this: a valid signature from your identity provider is not authorization. A token minted for some other service, replayed against your MCP server, will validate cryptographically and still be an attack. The audience claim is the whole game, and most ranking explainers skim past it to talk about discovery and PKCE.
Two framing points before the mechanics, because both are routinely missed.
First, MCP authorization is a remote/HTTP-transport story. The spec makes authorization OPTIONAL and ties it to HTTP-based transports only. STDIO servers — the local process your editor spawns over stdin/stdout — SHOULD NOT use OAuth at all; they pull credentials from the environment (GITHUB_TOKEN, a service-account JSON, whatever). If you are building OAuth flows for a local STDIO server, you are solving a problem the spec explicitly tells you not to solve. Conversely, if you expose a server over Streamable HTTP and treat it like the local case, you have shipped an open endpoint. The transport decides whether any of this applies.
Second, “what the spec requires” and “what teams ship” have diverged badly enough that there are CVEs. We will keep those two columns separate throughout.
The three roles
OAuth has three parties and MCP maps onto them cleanly. The confusion comes from an earlier draft that collapsed two of them.
- MCP server = OAuth 2.1 Resource Server (RS). It owns protected data/tools. It validates access tokens. It does not issue them.
- MCP client = OAuth 2.1 client. The host application (Claude Desktop, Cursor, your agent). It obtains tokens and sends them.
- Authorization Server (AS). Issues tokens. Can be your existing IdP (Okta, Auth0, Entra, Keycloak), a co-hosted component, or a third party. It is a separate role from the RS.
The 2025-03-26 revision of the spec had the MCP server act as both AS and RS — it ran the whole OAuth dance itself. That was a mistake: it forced every MCP server author to become an OAuth identity provider, with all the token-issuance, consent, and key-rotation surface that implies. The 2025-06-18 revision split RS from AS and made the resource-server posture primary. This is the single most important architectural correction in MCP auth, and it is why “be a pure resource server, delegate identity to an AS you already run” is the right default for almost everyone. You validate tokens; someone whose actual job is identity issues them.
The required flow, step by step
Here is the end-to-end handshake with each step marked MUST/SHOULD and tagged with the RFC that governs it. This is the 2025-11-25 shape.
- Client makes an unauthenticated request. Server responds
401 Unauthorizedwith aWWW-Authenticateheader carrying aresource_metadataURL (and SHOULD include the requiredscope). MUST. - Client fetches Protected Resource Metadata — RFC 9728. Served at
/.well-known/oauth-protected-resourceat the root, or/.well-known/oauth-protected-resource/<path>for path-scoped servers. This document MUST listauthorization_serverswith at least one AS. The MCP server MUST implement this; clients MUST use it for AS discovery. RFC 9728 tells the client where to authenticate. - Client fetches AS metadata — RFC 8414 (OAuth Authorization Server Metadata) or the equivalent OIDC discovery document, from the AS named in step 2. This yields
authorization_endpoint,token_endpoint,jwks_uri, etc. - Client runs the authorization-code flow with PKCE,
S256. PKCE is MANDATORY for MCP clients — not “recommended,” mandatory, and theplainmethod is not acceptable. The authorization request MUST include the RFC 8707resourceparameter set to the canonical URI of the MCP server (e.g.https://mcp.example.com). - Client exchanges the code at the token endpoint, again sending the RFC 8707
resourceparameter. The spec says clients MUST sendresourceon both the authorize and token requests regardless of whether the AS advertises support for it. This is what binds the issued token’s audience to your specific server. - Client calls the MCP server with
Authorization: Bearer <token>. Server validates and responds.
Note the discovery chain is 9728 → 8414/OIDC, not “RFC 9728 is OAuth 2.1.” Keep the RFCs straight, because the ranking tutorials don’t: 9728 = where to authenticate (RS metadata), 8707 = bind the token to one audience (the resource param), 8414/OIDC = AS metadata, 7591 = Dynamic Client Registration (now demoted, see below), and the Client ID Metadata Document draft = its replacement.
Audience binding: the part everyone skips
Steps 4 and 5 send resource. Step 6 is where it has to matter. The load-bearing requirement:
The MCP server MUST validate that the access token was issued for it as the intended audience, and MUST reject tokens that do not include it in the audience.
This is RFC 9068 (the JWT access-token profile) aud claim semantics, set up by the RFC 8707 resource binding. Concretely: your server decodes the JWT, checks the signature against the AS’s jwks_uri, checks iss is your AS, checks expiry — and then checks that aud equals your own canonical URI. A token whose aud is https://some-other-api.example.com MUST be rejected with 401, even though it is perfectly valid and unexpired. Insufficient scope on an otherwise-valid token gets 403 with WWW-Authenticate: error="insufficient_scope".
The reason this is non-negotiable: without the aud check, your server accepts any token your AS ever issued to any relying party. An attacker who obtains a token meant for a different downstream — through a malicious server, a leaked log, a compromised client — replays it at your endpoint and it works. Audience binding is the boundary that makes a stolen token useless outside the service it was minted for.
The good news is that in real SDKs this is a config field, not an essay. FastMCP implements the RS/AS split through a TokenVerifier protocol (PrefectHQ/fastmcp PR #1297). The JWT verifier is wired up like:
from fastmcp.server.auth.providers.jwt import JWTVerifier
verifier = JWTVerifier(
jwks_uri="https://auth.example.com/.well-known/jwks.json",
issuer="https://auth.example.com",
audience="https://mcp.example.com", # your canonical URI — this is the aud check
)
That audience line is the spec’s audience-binding requirement. Omit it, or set it to something permissive, and you have a server that validates signatures but not intent. A “pure resource server” does exactly this and nothing more: delegate identity to the external AS, validate the token and its claims, never issue anything.
Anti-pattern #1: token passthrough
The most common and most dangerous mistake: the MCP server takes the inbound token from the MCP client and forwards it, unchanged, to a downstream API it calls on the user’s behalf. The spec’s language is blunt:
MCP servers MUST NOT accept any tokens that were not explicitly issued for the MCP server.
The damage from passthrough falls into four buckets the spec enumerates:
- Control circumvention. The downstream’s rate limiting, request validation, and per-client throttling are scoped to the client they think they’re talking to. Passthrough launders requests through an identity the downstream never authorized for this path.
- Audit and accountability. Downstream logs now attribute actions to the original client identity, not the MCP server. Your audit trail lies about who did what.
- Trust boundary. You have accepted a credential issued for someone else and acted on it — exactly the audience violation above, now committed by your own server.
- Future-compatibility / blast radius. A stolen inbound token can use your server as a confused exfiltration proxy into every downstream you reach.
The correct downstream pattern — which the “don’t do passthrough” tutorials almost never show — is that your MCP server becomes a separate OAuth client to the upstream API, and obtains a different token for it. The clean mechanism is RFC 8693 OAuth Token Exchange, the on-behalf-of (OBO) grant: present the inbound token as the subject_token, ask the AS for a new token whose aud is the downstream, and receive a token with the same sub (the user’s identity is preserved) but a different aud (the downstream, not you).
POST /token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=<inbound token issued for https://mcp.example.com>
&subject_token_type=urn:ietf:params:oauth:token-type:access_token
&resource=https://downstream-api.example.com
&scope=files.read
You get back a token scoped to downstream-api.example.com. The user’s identity flows through; the audience does not. Auth0 documents this exact OBO exchange. The mental model: every hop re-mints. A token is a key cut for one lock.
Anti-pattern #2: the confused deputy
This one bites MCP proxy servers — a server that fronts a third-party AS on the client’s behalf. The vulnerable conditions are specific, and naming them precisely is the difference between understanding the attack and reciting a definition:
- the proxy uses a static
client_idto the third-party AS, - it allows Dynamic Client Registration for incoming clients,
- the third-party AS sets a consent cookie after first approval,
- and the proxy does not enforce per-client consent of its own.
The attack: the attacker dynamically registers a client with their own redirect_uri pointing at attacker.com, then sends the victim a crafted /authorize link. Because the victim already consented to the proxy’s static client_id once, the third-party AS finds its consent cookie and skips the consent screen. The authorization code is issued and redirected — to the attacker. Account takeover, no second click.
Mitigation checklist:
- The proxy MUST obtain per-client user consent before forwarding to the third-party AS, and store that consent server-side, keyed by
client_id. Never rely on the upstream’s consent cookie to gate a downstream client you registered dynamically. - Bind the OAuth
stateto a server-side session, set it only after consent, and make it single-use. Do not stuff client-controlled values (likeredirect_uri) intostate. - Enforce exact-match
redirect_uri, and protect the consent cookie (__Host-prefix, signed).
What actually broke in production
Each spec rule above exists because something shipped without it. Pairing the rule with the incident is the point.
CVE-2025-6514 — mcp-remote RCE. mcp-remote is the popular bridge that gives local clients OAuth access to remote MCP servers. A malicious server returned an authorization_endpoint containing shell metacharacters; mcp-remote passed it to the system shell, yielding full remote code execution on the client workstation. JFrog disclosed it in July 2025; the package had on the order of half a million weekly downloads (reports cite ~437k–559k depending on the snapshot), and CVSS landed at 9.6. Root cause: blind trust of server-provided OAuth metadata. The discovery model in step 2–3 above hands the server a lot of URLs that the client then acts on. Treat every field of resource_metadata and AS metadata as attacker-controlled input.
CVE-2025-49596 — MCP Inspector unauthenticated RCE. Anthropic’s own dev tool ran a proxy bound to localhost/0.0.0.0 with no authentication, reachable from a malicious web page via DNS rebinding, exposing the developer’s filesystem and API keys. Disclosed June 2025. This is the spec’s “MUST verify all inbound requests” rule, violated by a tool that assumed localhost meant trusted.
Obsidian Security’s Square one-click account takeover (reported Jul–Aug 2025, fixed late Sep 2025). A real remote MCP deployment that allowed DCR with no redirect_uri restriction, used a shared static client_id, embedded the client’s redirect_uri in the state param instead of binding state to a server-side session, and disabled consent on the shared proxy client. That is the confused-deputy condition list, checked off one by one, in production. The fix was initially applied only to newly registered clients — leaving every pre-fix client exploitable, a reminder that retrofitting auth must cover existing registrations.
GitHub MCP “data heist” (May 2025). An over-privileged Personal Access Token wired into the server, combined with untrusted issue/PR text reaching the model, let an attacker exfiltrate private-repo contents. People filed this under prompt injection, but the root cause is an authorization failure: a broad-scope, long-lived token sitting in reach of model-controlled input. Least privilege and short-lived, audience-bound tokens shrink the blast radius regardless of what the model is tricked into emitting.
The spec’s own 2025 timeline
The model moved three times in one year, which is why so much published guidance is stale:
- 2025-03-26 — MCP server is both AS and RS. It runs the whole OAuth flow itself.
- 2025-06-18 — RS/AS split. RFC 9728 (RS metadata) and RFC 8707 (resource indicators) made mandatory. Token-passthrough and confused-deputy guidance added.
- 2025-11-25 — DCR (RFC 7591) demoted from SHOULD to MAY (“included for backwards compatibility”). Client ID Metadata Documents (CIMD) introduced as the preferred no-prior-relationship mechanism. Enterprise-Managed Authorization (Cross App Access) added. OIDC discovery accepted as a 9728 alternative. Formal scope step-up flow specified.
Why DCR was a mistake: it forced every AS to accept programmatic client registration, which means unbounded database growth, an abuse/rate-limiting surface, and clients that re-register a fresh client_id on every login because they never persisted the last one. CIMD fixes this by making client_id an HTTPS URL the client controls — e.g. https://app.example.com/client.json — that hosts the client metadata. The client_id value MUST match that URL exactly; the AS fetches it to learn the client’s name, logo, and redirect URIs. No registration write, no row to grow, identity is self-describing and stable. (IndieAuth has used this pattern for a decade; Aaron Parecki, who edited the change, makes that lineage explicit.)
Enterprise-Managed Authorization is for organizations that want their IdP in the loop: the user SSOs to the MCP client, the client exchanges that ID token at the enterprise IdP, the IdP applies corporate policy and issues a short-lived identity-assertion (JAG) token the MCP AS accepts. Admins get visibility and revocation over every MCP connection without per-user consent screens. Early adopters include Asana, Atlassian, Canva, Figma, Linear, and Supabase.
Controls you still owe after OAuth is “done”
Audience binding and a clean downstream pattern are necessary, not sufficient. The spec’s Security Best Practices document carries several rules the OAuth tutorials drop:
- Scope minimization. Don’t publish omnibus scopes (
*,all,full-access) inscopes_supported— it inflates blast radius and drives consent abandonment. Advertise the required scope per resource in the401WWW-Authenticate, request least privilege, and handle runtime shortfalls with403 insufficient_scopeand a step-up authorization. - Sessions are not authentication. Servers MUST verify every inbound request and MUST NOT use sessions for auth. Session IDs MUST be non-deterministic and SHOULD be bound to user identity (e.g.
<user_id>:<session_id>). This closes session-hijack-by-impersonation and prompt-injection-via-shared-queue in multi-tenant deployments. - SSRF during discovery. A malicious server can point
resource_metadata,authorization_servers, ortoken_endpointathttp://169.254.169.254/(cloud metadata) orhttp://10.0.0.1. Server-side MCP clients MUST treat discovery as SSRF-exposed: block private/reserved ranges (RFC 9728 §7.7), enforce HTTPS, validate redirect targets, and guard DNS-rebinding TOCTOU. The spec explicitly warns against hand-rolled IP validation — octal, hex, and IPv4-mapped-IPv6 encodings bypass naive checks; use a vetted library. - Short-lived tokens + refresh rotation for public clients.
Decision guide
- Most teams: be a pure resource server with an external AS. Use the IdP you already run. Validate
audagainst your canonical URI; issue nothing. This is the smallest correct footprint. - Proxy / co-hosted AS only when you genuinely front a third party — and then the confused-deputy mitigations are mandatory, not optional.
- Enterprise-Managed Authorization when buyers need org-IdP visibility and central revocation.
- RFC 8693 token exchange whenever you call a downstream API — every hop re-mints.
- STDIO servers skip all of this and read credentials from the environment.
If you ship one thing correctly, ship the aud check. The rest of MCP authorization is plumbing you can buy off the shelf; that single comparison is the boundary everything else rests on.