Secure your MCP Servers using OAuth 2.1 - Part 2
- amareshpat
- Nov 11, 2025
- 5 min read
Here’s a concrete FastMCP-based setup that wires in real MCP-style OAuth 2.1 auth, including:
a remote HTTP MCP server
proper OAuth 2.1 / PRM (Protected Resource Metadata) wiring via FastMCP’s RemoteAuthProvider
token-based authorization hooks inside tools
a sample client using FastMCP’s OAuth helper to log in and call tools
I’ll assume you already have an external IdP (e.g. https://auth.example.com) that:
issues JWT access tokens
supports OAuth 2.1 + Authorization Server Metadata
supports Dynamic Client Registration (DCR), which is what MCP really wants for “automatic” client onboarding. (FastMCP)
1. Server: FastMCP + Remote OAuth 2.1
FastMCP already has the primitives you want:
JWTVerifier – validates JWT access tokens (issuer, audience, signatures, expiry). (FastMCP)
RemoteAuthProvider – exposes OAuth 2.0 Protected Resource Metadata (/.well-known/oauth-protected-resource) and ties it to your IdP. (FastMCP)
HTTP transport – to expose your MCP server at a URL like https://mcp.example.com/mcp. (FastMCP)
1.1 Install deps
uv pip install fastmcp "pydantic>=2"
# or: pip install fastmcp pydantic
1.2 server.py: a protected Weather MCP server
# server.py
from fastmcp import FastMCP
from fastmcp.server.auth import RemoteAuthProvider
from fastmcp.server.auth.providers.jwt import JWTVerifier
from fastmcp.server.dependencies import get_access_token, AccessToken
from pydantic import AnyHttpUrl
# --- Auth configuration -------------------------------------------------
# 1) How to validate incoming Bearer tokens (JWT from your IdP)
token_verifier = JWTVerifier(
jwks_uri="https://auth.example.com/.well-known/jwks.json",
issuer="https://auth.example.com",
audience="https://mcp.example.com", # this should match your PRM `resource`
)
# 2) How to advertise your IdP to MCP clients via PRM (RFC 9728)
auth = RemoteAuthProvider(
token_verifier=token_verifier,
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
base_url="https://mcp.example.com", # canonical HTTPS origin of this server
# Allow localhost redirect URIs for dev MCP clients (ChatGPT, FastMCP Client, etc.)
allowed_client_redirect_uris=[
"http://localhost:*",
"http://127.0.0.1:*",
],
)
# --- FastMCP server -----------------------------------------------------
mcp = FastMCP(
name="WeatherMCP",
auth=auth, # <- this turns on OAuth 2.1 / PRM for HTTP transport
)
# --- Example tool with authorization logic ------------------------------
@mcp.tool
async def current_weather(city: str) -> dict:
"""
Return fake weather for demo.
Requires `weather:read` scope on the access token.
"""
token: AccessToken | None = get_access_token()
if token is None:
# FastMCP will already have rejected unauthenticated calls,
# but this guards against misconfig / non-HTTP transports.
raise PermissionError("Not authenticated")
# Enforce scopes (or roles) from the token
required_scope = "weather:read"
if required_scope not in (token.scopes or []):
raise PermissionError(f"Missing required scope: {required_scope}")
user_id = token.claims.get("sub", token.client_id or "unknown")
# In a real app you’d call an external API here.
# This is just a stubbed response.
return {
"city": city,
"conditions": "sunny",
"temperature_c": 23.5,
"requested_by": user_id,
"scopes": token.scopes,
}
# Optional: a resource that’s also protected
@mcp.resource("weather://cities")
async def list_supported_cities() -> list[str]:
return ["San Francisco", "Berlin", "Tokyo", "Bangalore"]
if __name__ == "__main__":
# HTTP transport exposes an MCP endpoint at /mcp
# e.g. https://mcp.example.com/mcp in production
mcp.run(transport="http", host="0.0.0.0", port=8000)
What this gives you:
RemoteAuthProvider automatically mounts:
/.well-known/oauth-protected-resource on your server (PRM)
401 challenges with WWW-Authenticate: Bearer resource_metadata=... so MCP clients know where to fetch PRM. (FastMCP)
PRM returns something like:
{ "resource": "https://mcp.example.com", "authorization_servers": ["https://auth.example.com"], "bearer_methods_supported": ["header"], "scopes_supported": ["weather:read"] }
(FastMCP builds this from base_url + authorization_servers + verifier config.) (FastMCP)
Incoming HTTP requests to /mcp must include a valid OAuth 2.1 Bearer token, or they get a 401 with a PRM pointer, per the MCP Authorization spec. (Model Context Protocol)
2. How OAuth 2.1 actually works in this setup
With this wiring, the flow is:
MCP client calls POST https://mcp.example.com/mcp without a token → server responds 401 Unauthorized with WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource" and often scope="weather:read". (Model Context Protocol)
The MCP client fetches PRM from /.well-known/oauth-protected-resource to discover:
canonical resource
trusted authorization_servers
scopes, token format, etc. (Descope)
Using PRM, the client picks an authorization server (e.g. https://auth.example.com) and fetches its Authorization Server Metadata (/.well-known/oauth-authorization-server or OIDC Discovery). (Model Context Protocol)
Client runs OAuth 2.1 Authorization Code with PKCE:
Generates a code_verifier/code_challenge.
Opens browser to the IdP’s authorization endpoint with response_type=code, code_challenge, resource=https://mcp.example.com, and scope=weather:read.
User logs in, consents.
IdP redirects back to the client’s local redirect URI (http://127.0.0.1:<random-port>/callback).
Client swaps code for access_token (and refresh token) at the IdP’s token endpoint, using PKCE (no client secret needed). (FastMCP)
The client caches tokens and adds Authorization: Bearer <access_token> in subsequent MCP calls.
FastMCP’s auth providers take care of:
3. Using auth claims & scopes in tools
Above we used get_access_token() to pull auth context into a tool. That’s the “auth hook” you’ll use most often. The pattern is:
from fastmcp.server.dependencies import get_access_token, AccessToken
@mcp.tool
async def admin_op() -> str:
token: AccessToken | None = get_access_token()
if token is None:
raise PermissionError("Not authenticated")
if "admin:write" not in (token.scopes or []):
raise PermissionError("admin:write scope required")
user_id = token.claims.get("sub") or token.client_id
# Perform privileged operation...
return f"OK for user {user_id}"
The AccessToken object gives you:
token.scopes – list of scopes granted
token.claims – raw JWT claims (sub, tenant_id, roles, etc.)
token.client_id – derived from standard claims like client_id or sub. (FastMCP)
This lets you implement:
tenant-aware tools (filter by tenant_id claim),
per-user ACLs (sub),
tool-level permission checks (scopes / roles).
4. Client: connecting with FastMCP’s OAuth helper
Now let’s show a simple Python client that connects to your protected server using OAuth 2.1.
FastMCP has an OAuth helper that:
discovers PRM from /.well-known/oauth-protected-resource,
discovers authorization server metadata,
does Authorization Code + PKCE,
runs a local callback server,
manages token refresh and storage. (FastMCP)
4.1 client.py: OAuth client calling the WeatherMCP server
# client.py
import asyncio
from fastmcp import Client
from fastmcp.client.auth import OAuth
async def main():
mcp_url = "https://mcp.example.com/mcp"
# Configure OAuth 2.1 client
oauth = OAuth(
mcp_url=mcp_url,
scopes=["weather:read"], # must match what your server expects
client_name="Weather Demo Client" # optional, for DCR
# token_storage=... # plug in encrypted storage in real apps
)
# Connect using HTTP transport + OAuth 2.1
async with Client(mcp_url, auth=oauth) as client:
# Make sure we can talk to the server
await client.ping()
tools = await client.list_tools()
print("Available tools:", [t.name for t in tools])
# Call our protected tool
result = await client.call_tool(
name="current_weather",
arguments={"city": "San Francisco"},
)
# result.content[0].text / .json etc depending on your return type
print("Weather:", result.content[0].as_json())
if __name__ == "__main__":
asyncio.run(main())
What happens when you run this:
Client(..., auth=oauth) makes a call to mcp_url → gets 401 with WWW-Authenticate → fetches PRM. (FastMCP)
OAuth helper follows the PRM’s authorization_servers to find IdP metadata.
If no valid token exists in token_storage, it:
starts a local callback server (e.g. http://127.0.0.1:51123/callback),
opens your browser to the IdP login/consent screen,
exchanges the code for tokens, stores them, and continues.
Subsequent runs reuse cached tokens until they expire, at which point OAuth refreshes them.
This is all fully compliant with MCP’s Authorization spec (OAuth 2.1, PRM, DCR) as long as your IdP implements those pieces. (Model Context Protocol)
5. Minimal “dev-only” alternative: JWT-only verification
If you just want a local dev server without the full browser OAuth flow, you can swap RemoteAuthProvider for JWTVerifier directly:
from fastmcp import FastMCP
from fastmcp.server.auth.providers.jwt import JWTVerifier
auth = JWTVerifier(
jwks_uri="https://auth-dev.example.com/.well-known/jwks.json",
issuer="https://auth-dev.example.com",
audience="https://mcp-dev.example.com",
)
mcp = FastMCP(name="DevServer", auth=auth)
Then you manually mint tokens (e.g. with JWTVerifier.create_token) and pass them as Authorization: Bearer <token> from your own client. (FastMCP)
This still gives you proper token validation but skips PRM + DCR; so it’s not fully spec-compliant for ChatGPT / generic MCP clients, but it’s handy while you’re iterating.
6. Where to go deeper
If you want to push this into production, the next steps I’d explore:
Use a proper DCR-capable IdP (Descope, WorkOS AuthKit, modern OIDC servers) and configure it with RemoteAuthProvider. (FastMCP)
Or use OAuthProxy if your IdP doesn’t support DCR (GitHub / Google / Azure, etc.). (FastMCP)
Add middleware that maps token claims → app-level roles, and store them in ctx.set_state(...) for tools to consume. (FastMCP)
If you tell me which IdP you’re actually using (Auth0, WorkOS, Descope, Entra, etc.), I can adapt this example to that provider’s exact config and env vars.


Comments