MCP OAuth: How OAuth 2.1 Works in the Model Context Protocol
Every MCP server that touches real data needs authentication. The Model Context Protocol spec mandates OAuth 2.1 as the standard, which means every team deploying MCP servers to production will eventually need to understand how the OAuth flow works in this context, where it diverges from what you already know about OAuth, and where the sharp edges are.
This guide covers the full OAuth 2.1 flow as it applies to MCP servers: metadata discovery, dynamic client registration, PKCE-protected authorization, and token lifecycle management. If you've implemented OAuth before, some of this will look familiar. The MCP-specific parts are worth paying attention to.
Why OAuth 2.1 for MCP?
The MCP spec could have gone with API keys or mutual TLS or any number of simpler schemes. OAuth 2.1 won because MCP servers sit at a unique intersection: they serve multiple clients (Claude Desktop, Cursor, custom agents, automation scripts), those clients act on behalf of different users, and the server needs to know who is asking for what.
API keys can't distinguish between users. Mutual TLS is painful to distribute to desktop apps. OAuth 2.1 solves the multi-client, multi-user problem natively because that's exactly what it was designed for.
OAuth 2.1 also brings a couple of security improvements over OAuth 2.0 that matter here. It requires PKCE for all flows, not just public clients. It drops the implicit grant entirely. And it requires exact redirect URI matching. These constraints reduce the attack surface for MCP clients, which often run as local desktop applications where the threat model is different from a typical web app.
The MCP OAuth Flow, Step by Step
The flow has five stages. The first two happen once per client-server pair. The last three happen every time a client needs a fresh token.
1. Server Metadata Discovery
Before anything else, the MCP client needs to learn how the server handles auth. The client sends a GET request to the server's well-known metadata endpoint:
GET https://mcp-server.example.com/.well-known/oauth-authorization-server
The response looks like this:
{
"issuer": "https://mcp-server.example.com",
"authorization_endpoint": "https://mcp-server.example.com/authorize",
"token_endpoint": "https://mcp-server.example.com/token",
"registration_endpoint": "https://mcp-server.example.com/register",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none"]
}
Two things to notice. First, response_types_supported only includes code. No implicit flow. Second, code_challenge_methods_supported only includes S256. Plain PKCE challenges are not allowed.
If the server doesn't expose this endpoint, the client falls back to RFC 8414 defaults. Most production servers should expose it explicitly because the fallback behavior varies across client implementations.
2. Dynamic Client Registration
Here's where MCP diverges from the OAuth you're used to. In a typical web app, you register your OAuth client manually: go to the provider's dashboard, create an app, copy your client ID and secret. That doesn't work for MCP because clients are diverse, numerous, and often user-installed.
Dynamic client registration (RFC 7591) lets the client register itself programmatically:
POST https://mcp-server.example.com/register
Content-Type: application/json
{
"client_name": "My AI Agent",
"redirect_uris": ["http://localhost:8400/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
}
The server responds with a client_id (and sometimes a client_secret, depending on the server's policy). The client stores these credentials and reuses them for subsequent auth flows.
This is the part that trips up most implementers. Dynamic registration means any client can register with your server unless you add your own validation layer on top. For internal MCP servers, you probably want to restrict registration to clients from known origins or require a pre-shared registration token. For public-facing servers, you need to think carefully about what a registered client can actually do.
3. Authorization Request with PKCE
With a client ID in hand, the client starts the authorization code flow. PKCE (Proof Key for Code Exchange) is mandatory in MCP's OAuth 2.1 profile.
The client generates a random code_verifier (a high-entropy string, 43-128 characters) and derives a code_challenge from it:
import hashlib
import base64
import secrets
code_verifier = secrets.token_urlsafe(32)
code_challenge = base64.urlsafe_b64encode(
hashlib.sha256(code_verifier.encode()).digest()
).rstrip(b"=").decode()Then it redirects the user to the authorization endpoint:
GET https://mcp-server.example.com/authorize?
response_type=code
&client_id=abc123
&redirect_uri=http://localhost:8400/callback
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
&state=xyz789
The user authenticates (however the server handles that, whether it's a login form, SSO redirect, or something else) and the server redirects back with an authorization code.
4. Token Exchange
The client exchanges the authorization code for tokens, proving it's the same client that initiated the flow by including the original code_verifier:
POST https://mcp-server.example.com/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=http://localhost:8400/callback
&client_id=abc123
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
The server verifies the code_verifier against the stored code_challenge, and if they match, returns an access token and refresh token:
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA"
}5. Using Tokens with MCP Requests
The client includes the access token in subsequent MCP requests:
POST https://mcp-server.example.com/mcp
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
Content-Type: application/json
{
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "query_database",
"arguments": {"sql": "SELECT count(*) FROM users"}
},
"id": 1
}
When the access token expires, the client uses the refresh token to get a new one without requiring user interaction again. Token rotation (issuing a new refresh token with each use and invalidating the old one) is recommended by OAuth 2.1 and should be implemented by MCP servers handling sensitive data.
Implementing OAuth in FastMCP
FastMCP handles most of the OAuth complexity for server authors. Here's what a server with OAuth looks like:
from fastmcp import FastMCP
from fastmcp.server.auth import OAuthProvider
mcp = FastMCP(
"My Secure Server",
auth=OAuthProvider(
issuer="https://my-server.example.com",
authorize_url="https://my-server.example.com/authorize",
token_url="https://my-server.example.com/token",
client_registration_url="https://my-server.example.com/register",
scopes=["read", "write", "admin"],
),
)
@mcp.tool()
def query_database(sql: str) -> str:
"""Run a read-only SQL query."""
# The auth context is available via the request context
# FastMCP validates the token before this code runs
return execute_query(sql)FastMCP generates the metadata endpoint, handles token validation on incoming requests, and rejects unauthorized calls before your tool code executes. You still need to implement the actual authorization server (the login page, user database, token storage), or delegate to an existing identity provider like Auth0, Okta, or your corporate SSO.
That delegation is where things get real. Writing a spec-compliant OAuth authorization server from scratch is a multi-month project. Most teams should use an existing provider and configure FastMCP to validate tokens issued by that provider rather than building their own.
Where Teams Get Stuck
After working with dozens of teams deploying MCP servers to production, a few patterns keep showing up.
Redirect URI handling for desktop clients. MCP clients like Claude Desktop and Cursor are desktop apps, not web apps. They can't receive HTTP redirects to a public URL. The standard approach is to start a temporary local HTTP server (usually on localhost:8400 or a random port), use that as the redirect URI, and shut it down after receiving the callback. This works, but you need to handle port conflicts and make sure the server only accepts the expected callback.
Token storage across sessions. When a user restarts their MCP client, they don't want to re-authenticate with every server. The client needs to persist tokens securely. On macOS, that means Keychain. On Windows, the Credential Manager. On Linux, the situation is less standardized, and some clients fall back to encrypted files in the user's home directory. If you're building a custom MCP client, get this right early because users will notice if they have to log in every time they open the app.
Scope management at scale. A single MCP server might expose tools with very different privilege levels: a read-only analytics query and a write operation that modifies production data. OAuth scopes let you gate access to specific tools, but the MCP spec doesn't prescribe a scope naming convention. Teams end up inventing their own (read:database, write:database, admin:*) and then struggling to keep scopes consistent across a fleet of servers. Having a naming convention before you deploy your second server saves a lot of cleanup later.
Clock skew in token validation. JWT validation includes checking the exp (expiration) and nbf (not before) claims. If your MCP server and the authorization server have drifting clocks, valid tokens get rejected. This is surprisingly common in containerized environments where the host clock and container clock can diverge. NTP should be running everywhere, but verify it.
The Complexity Budget
OAuth 2.1 for MCP is not hard conceptually. Each step makes sense on its own. The complexity comes from the number of moving parts that all need to work together: metadata discovery, dynamic registration, PKCE generation, token exchange, token storage, token refresh, token rotation, scope validation, clock synchronization. Miss any one of these and the system breaks in ways that are hard to debug because the failure mode is usually "the client gets a 401 and the user has no idea why."
For teams deploying one or two internal MCP servers, implementing OAuth yourself with FastMCP and an existing identity provider is reasonable. The investment pays off in understanding and control.
For teams deploying ten or twenty servers across multiple teams, the operational cost of managing OAuth configurations, token policies, and scope conventions across every server starts to dominate. That's the problem Horizon Deploy solves: it handles OAuth, token management, and access control as part of the deployment, so each server doesn't need its own auth implementation. Your servers deploy with authentication configured, and Horizon Gateway manages access policies across the fleet.
Whether you implement OAuth yourself or use a managed platform, the important thing is to get auth right before you go to production. An MCP server without authentication is an MCP server that anyone on your network can call. That's fine for local development. It's not fine for anything that touches real data.
Further Reading
- MCP Authorization Specification covers the full protocol requirements
- OAuth 2.1 draft for the underlying standard
- FastMCP Authentication docs for implementation details
- RFC 7591: Dynamic Client Registration for the registration protocol
- RFC 7636: PKCE for the proof key exchange mechanism