TL;DR
A remote MCP server is an OAuth 2.1 resource server: it accepts access tokens on every tool call, validates them against the issuer and audience it expects, and rejects anything that fails, on every request, not just at connection time. The spec has mandated OAuth 2.1 for remote servers since the March 2025 revision. What the spec does not do for you is the part that actually fails in practice: getting PKCE right, scoping tokens to the least a tool needs, and a CORS mistake on the token endpoint that silently breaks browser-based clients while working fine from a terminal.
This is the architecture, not the concept. If you have not yet decided whether your server needs auth at all, the build guide's auth table covers that first.
Exposing internal systems to an AI client for the first time? We design the authorization boundary before the first tool ships, not after the first incident. Least-privilege scopes, token verification, and the CORS setup that actually works.
Key Takeaways
- Since the March 2025 spec revision, a remote MCP server is an OAuth 2.1 resource server. It must validate the access token on every tool call, not once at session start.
- PKCE is not optional in OAuth 2.1, for any client type, confidential or public. Skipping it reopens the authorization-code interception attack PKCE exists to close.
- Token verification means checking audience, issuer, expiry, and scope, in that order, before a tool ever executes. A token that is merely present is not a token that is valid for this server.
- The most common silent failure is missing CORS headers on the token endpoint, not the authorize endpoint. The flow works from a terminal client and fails from a browser-based one, for reasons that look nothing like an auth bug.
- Scope design should map to tools, not to the whole server. A token that grants everything is a token that turns one compromised tool call into a compromised account.
Why does MCP require OAuth 2.1 specifically?
Earlier drafts of the spec left authorization loosely defined, and remote MCP servers, ones reachable by more than a single trusted process, inherited whatever ad hoc scheme each implementer chose. The March 2025 revision closed that gap: a remote server must act as an OAuth 2.1 resource server, accepting bearer tokens issued by a separate authorization server and validating them per request. Local, stdio-launched servers are exempt; they inherit the trust boundary of the machine they run on, which is why the build guide treats auth as optional there and mandatory here.
OAuth 2.1 itself is not a new protocol so much as OAuth 2.0 with the footguns removed: PKCE required for every flow, the implicit grant dropped, and stricter redirect URI matching. MCP adopting it wholesale, rather than defining its own scheme, is the same decision the whole industry made after a decade of custom auth implementations turning out to be the vulnerability.
The role confusion that causes most of the pitfalls
The mistake that catches teams building their first secured MCP server is architectural, not cryptographic: treating the MCP server itself as the identity provider. It is tempting, the server already has a database, adding a users table and issuing your own tokens feels like less moving parts. It is also how you end up maintaining a hand-rolled authorization server with none of the audits a dedicated one has behind it.
The pattern that holds up separates the roles cleanly. A dedicated authorization server, Auth0, a corporate IdP, or a purpose-built one, issues and signs tokens. The MCP server's only job is resource server: verify the token on each request, check what it is allowed to do, execute or refuse. When the MCP server also needs to call a downstream API on the user's behalf, GitHub, an internal system, it takes on a third role, OAuth client to that downstream service, with its own separately scoped credentials. Three roles, three concerns, none of them the MCP server inventing its own login system.
What does PKCE actually add?
PKCE, Proof Key for Code Exchange, closes a specific gap: without it, an attacker who intercepts the authorization code mid-flow, on a shared device, through a malicious redirect, through a logged proxy, can exchange it for a token themselves. PKCE makes the client generate a random secret before the flow starts, send a hash of it with the authorization request, and present the original secret when exchanging the code for a token. An intercepted code is useless without the secret that was never transmitted alongside it.
OAuth 2.1 made PKCE mandatory for every client type, not just public ones without a client secret, which is stricter than OAuth 2.0 ever required. For MCP this matters because MCP clients span the full range from a desktop app with a securely stored secret to a browser-based tool with none. Implementing PKCE once, correctly, covers both without a client-type branch in your authorization logic.
The CORS mistake that breaks browser clients silently
This is the failure that costs the most debugging time, because it does not look like an auth problem. A terminal-based MCP client completes the OAuth flow fine. A browser-based one gets to the token exchange and fails, with an error that mentions cross-origin requests, not tokens or scopes.
The cause is almost always the same: CORS headers configured on the /authorize endpoint, because that is the one a browser visibly redirects to, and forgotten on the /token endpoint, because that call happens in the background via fetch or XMLHttpRequest. A browser enforces CORS on that background call the same way it does on any other cross-origin request. Without an Access-Control-Allow-Origin header on the token endpoint's response, the browser discards it before your client-side code ever sees the token, regardless of whether the server-side exchange actually succeeded.
The fix is to set CORS headers on every endpoint a browser-based client touches directly, not just the one it navigates to visibly, and to test the flow from an actual browser client before assuming a terminal-based test proves the server is correctly configured.
Token verification and scope design
A token arriving at a tool call is not trustworthy because it is present. It is trustworthy after four checks, in order: the signature validates against the authorization server's published keys, the issuer matches the authorization server you actually trust, the audience claim names this specific MCP server and not some other resource the token was issued for, and the token has not expired. Skip the audience check specifically and a token issued for a different service becomes usable against yours, a mistake that costs nothing to make and everything to have made.
Scope design is where least-privilege either happens or does not. A single mcp:access scope that unlocks every tool is the equivalent of one admin password for the whole server: convenient to implement, catastrophic the moment one token leaks. Scope per capability instead, projects:read, projects:write, billing:read, so a token compromised through one tool cannot reach tools it was never granted.
| Requirement | Why it exists | What breaks if skipped |
|---|---|---|
| PKCE on every authorization flow | Closes the authorization-code interception attack | A leaked or intercepted code becomes a usable token |
| Audience validation on every token | Confirms the token was issued for this server specifically | A token for a different service becomes valid here |
| Per-tool scope enforcement | Limits blast radius of one compromised token | One leaked token grants access to every tool, not one |
| Short-lived tokens with refresh | Bounds how long a leaked token stays useful | A stolen token stays valid for the token's full lifetime |
| CORS on the token endpoint, not just authorize | Browser-based clients need it on the background call too | Flow works from a terminal, fails silently from a browser |
Frequently asked questions
Does every MCP server need OAuth 2.1?
Only remote servers, ones reachable by more than your own local process. A stdio server launched directly by a desktop client inherits that machine's trust boundary and typically needs no OAuth flow at all. The moment a server is reachable over HTTP by clients you do not control, OAuth 2.1 is what the specification requires.
What is PKCE and why does MCP require it?
PKCE, Proof Key for Code Exchange, prevents an intercepted authorization code from being exchanged for a token by anyone other than the client that started the flow. OAuth 2.1 makes it mandatory for every client type, which MCP inherited wholesale rather than defining its own weaker scheme.
Why does my MCP OAuth flow work in a terminal but fail in a browser?
Almost always missing CORS headers on the token endpoint. Browsers enforce CORS on the background token exchange the same way they do on any cross-origin call, while terminal-based clients are not subject to CORS at all. Set Access-Control-Allow-Origin on the token endpoint's response, not just the authorize endpoint.
Should an MCP server issue its own OAuth tokens?
No, not by default. The recommended pattern separates the authorization server, which issues and signs tokens, from the MCP server, which only validates them as a resource server. An MCP server that also invents its own login system is maintaining unaudited authentication code it does not need to own.
What scopes should an MCP server use?
Scope per tool or capability, not one scope for the whole server. A token compromised through a single leaked call should only grant whatever that specific tool needed, projects:read rather than a blanket mcp:access, so the damage from one leak stays bounded to what that scope actually covers.



