
This tutorial is part of Build an MCP Server, within The MCP Guide. Want it built for you? See MCP development.
To add OAuth to a remote MCP server, you connect it to an authorization server, such as your identity provider, and make the MCP server verify the access token on every request. The MCP server never signs anyone in. It checks that each token is valid, was issued by your provider, is meant for this server, and has the right scopes. The SDK handles discovery metadata and the 401 response for you.
This guide is the practical version: it replaces a demo token table with real JWT verification against a provider's published keys. It ran end to end locally against a stand-in identity provider. For the concepts, start with our Learn MCP lesson on OAuth, which this guide builds on.
Checked against: the MCP authorization specification (2026-07-28), the Python SDK's authorization guide and WorkOS's MCP docs on 2 November 2026, with mcp 2.3.0 and PyJWT 2.15.1. The server ran locally on 2 November 2026 with a test-only key server standing in for a real provider, and was called through MCP Inspector 2.13.0.
Key Takeaways
- The spec makes authorization optional, but when you use it over HTTP, your server "MUST implement OAuth 2.0 Protected Resource Metadata (RFC9728)."
- Your server must check the token's audience. The spec says servers "MUST validate that access tokens were issued specifically for them as the intended audience."
- Verify JWTs with the provider's JWKS: signature, issuer, audience and expiry. Return
Noneon any failure and the SDK sends a 401. - Client ID Metadata Documents (CIMD) are the recommended client registration method. Dynamic Client Registration is "deprecated and retained for backwards compatibility."
- Never pass the user's token through to another API. Your server uses its own credentials downstream.
Step 1: Choose an authorization server
You need a service that signs users in and issues tokens. Options with MCP documentation include:
| Option | What its docs say | Notes |
|---|---|---|
| WorkOS AuthKit | Tokens carry an aud that "matches the requested resource" once you add a Resource Indicator | CIMD is off by default; enable it in the dashboard |
| Auth0 | Offers "standards-aligned discovery and registration" for MCP, per its Auth for MCP page | Also offers token exchange for calling your own APIs |
| Cloudflare Workers OAuth Provider | An authorization server Worker in front of an MCP Worker, per our course lesson | Good if you host on Workers |
| Your existing IdP | Any OAuth 2.1 provider that issues JWTs with an audience | Check CIMD or DCR support for your target hosts |
For a side-by-side view with pricing, see the best MCP auth providers and WorkOS vs Auth0 for MCP.
Whatever you pick, check three things in its docs:
- Audience control. Can tokens carry your MCP server's URL as
aud? WorkOS says that with no Resource Indicators configured,audfalls back to the client ID, which would fail an audience check. - Client registration. The spec says authorization servers and clients "SHOULD support" CIMD, and "MAY support" Dynamic Client Registration.
- Published keys. A JWKS URL, such as WorkOS's
https://<authkit_domain>/oauth2/jwks, lets you verify tokens locally.
Step 2: Install
uv init mcp-oauth --python 3.12
cd mcp-oauth
uv add "mcp[cli]" "pyjwt[crypto]"
Step 3: Write a JWT token verifier
The Python SDK asks you for one thing: a TokenVerifier with an async verify_token method. Its authorization guide says a real one "verifies a JWT signature or calls the authorization server's token-introspection endpoint." Create server.py:
import os
import anyio
import jwt
from pydantic import AnyHttpUrl
from mcp.server import MCPServer
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.provider import AccessToken, TokenVerifier
from mcp.server.auth.settings import AuthSettings
from mcp.server.mcpserver.exceptions import ToolError
ISSUER = os.environ["OAUTH_ISSUER"] # e.g. https://your-tenant.authkit.app
JWKS_URL = os.environ["OAUTH_JWKS_URL"] # e.g. https://your-tenant.authkit.app/oauth2/jwks
RESOURCE = os.environ["MCP_RESOURCE_URL"] # this server's public URL, e.g. https://mcp.example.com/mcp
class JWTVerifier(TokenVerifier):
"""Verify signature, issuer, audience and expiry of a JWT access token."""
def __init__(self) -> None:
self.jwks = jwt.PyJWKClient(JWKS_URL, cache_keys=True)
async def verify_token(self, token: str) -> AccessToken | None:
try:
key = await anyio.to_thread.run_sync(self.jwks.get_signing_key_from_jwt, token)
claims = jwt.decode(
token, key.key, algorithms=["RS256", "ES256"],
issuer=ISSUER, audience=RESOURCE,
options={"require": ["exp", "iss", "aud", "sub"]},
)
except jwt.PyJWTError:
return None # the SDK answers 401 invalid_token
scope = claims.get("scope", "")
return AccessToken(
token=token,
client_id=claims.get("client_id") or claims.get("azp") or claims["sub"],
scopes=scope.split() if isinstance(scope, str) else list(scope),
expires_at=claims["exp"],
resource=RESOURCE, # jwt.decode already checked aud == RESOURCE
subject=claims["sub"],
)
What the verifier checks:
- Signature.
PyJWKClientfetches the provider's public keys and picks the one named in the token'skidheader. The fetch runs in a worker thread, so a slow key download does not block the server. - Algorithms. A fixed list stops a token from choosing a weak algorithm.
- Issuer. Only tokens from your provider pass.
- Audience.
audience=RESOURCErejects tokens issued for any other API. This is the spec's audience rule in one argument. - Required claims. A token without an expiry or subject is refused.
Claim names for scopes and client IDs vary by provider. Check your provider's token reference and adjust scope, client_id or azp.
Step 4: Wire it into the server
Add the server and tools below the verifier:
mcp = MCPServer(
"orders",
token_verifier=JWTVerifier(),
auth=AuthSettings(
issuer_url=AnyHttpUrl(ISSUER),
resource_server_url=AnyHttpUrl(RESOURCE),
required_scopes=["orders:read"],
validate_token_resource=True,
),
)
@mcp.tool()
def my_orders() -> str:
"""List the signed-in customer's orders."""
token = get_access_token()
if token is None:
raise ToolError("Not signed in.")
return f"Orders for {token.subject}: none yet."
@mcp.tool()
def cancel_order(order_id: str) -> str:
"""Cancel one of the signed-in customer's orders."""
token = get_access_token()
if token is None or "orders:write" not in token.scopes:
raise ToolError("This action needs the orders:write permission.")
return f"Cancelled {order_id} for {token.subject}."
if __name__ == "__main__":
mcp.run(transport="streamable-http")
Per the SDK's authorization guide:
issuer_urlnames your authorization server. It appears in the discovery document.resource_server_urlis this server's public MCP URL.required_scopesmust be on every token.validate_token_resource=Truerefuses tokens whoseresourcedoes not match. The guide says leaving it unset warns and behaves asFalse, and that 3.0 makesTruethe default.get_access_token()returns the verified token inside a tool.
Step 5: Test it
On 2 November 2026, a small test-only key server published a JWKS and minted three tokens. Then:
The discovery document, which the SDK generated:
{"resource":"http://127.0.0.1:8000/mcp","authorization_servers":["http://127.0.0.1:9000/"],"scopes_supported":["orders:read"],"bearer_methods_supported":["header"]}
A request with no token:
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer error="invalid_token", error_description="Authentication required", resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp"
Inspector calls with --header "Authorization: Bearer <token>":
| Token | my_orders | cancel_order |
|---|---|---|
Correct audience, orders:read | "Orders for user_alice: none yet." | Tool error: needs orders:write |
| Correct audience, read and write | Success | "Cancelled 1001 for user_bob." |
Audience https://other-api.example.com | HTTP 401 | HTTP 401 |
The third row is the one that matters most. A perfectly valid token from the same provider, issued for a different API, was refused. Our Inspector lesson shows how to run these checks in CI with --stored-auth-only.
Step 6: Register your server with the provider
In your provider's dashboard:
- Add your MCP URL as an API, audience or resource indicator. It must exactly match
MCP_RESOURCE_URL. The spec says clients send this as theresourceparameter, and that the canonical form has no trailing slash unless the slash is meaningful. - Define scopes, such as
orders:readandorders:write. - Enable CIMD if your provider supports it, and DCR only if a host you need still requires it.
- Allow the redirect URIs of the hosts you support, such as Claude's connector callback.
Step 7: Ask for more scope the standard way
The spec says that when a token lacks a scope, the server "SHOULD" respond with 403 Forbidden and a WWW-Authenticate header with error="insufficient_scope" and the needed scope. That lets the client ask the user for more access. The tool-error approach above is simpler and always works, but it does not trigger step-up sign-in. The TypeScript SDK has a per-tool scope challenge for this; check your SDK's docs for current support.
Deploy notes
- HTTPS only. The spec's security considerations require HTTPS for authorization server endpoints. Run your MCP server behind TLS too.
- Behind a load balancer. The 2026-07-28 spec removed protocol sessions, so each request carries its token and any instance can serve it.
- Calling other APIs. The spec says servers "MUST NOT accept or transit any other tokens." If tools call Stripe or your own back end, use the server's own credentials, or your provider's token exchange. See token passthrough.
- Stdio servers. The spec says stdio implementations "SHOULD NOT follow this specification, and instead retrieve credentials from the environment."
Pitfalls
- Skipping the audience check. Without it, any token from your provider works on your MCP server. That is the confused deputy risk; see our confused deputy page.
- Trailing slash mismatch.
https://mcp.example.com/mcp/andhttps://mcp.example.com/mcpare different audiences to a JWT library. - Default
aud. Some providers put the client ID inaudunless you configure a resource. Decode a real token and look. - Building your own authorization server. The SDK guides point to a dedicated identity provider instead.
- Old imports.
mcp.server.fastmcpis gone in SDK v2; usefrom mcp.server import MCPServer.
For more mistakes to avoid, read common MCP OAuth misconfigurations.
Next step
The free Learn MCP course covers deployment, least privilege and logging after auth. If you need OAuth set up against your company's identity provider, with scopes mapped to real roles, eSEOspace MCP development can help.
More in the MCP Guide
Free tools: server.json Validator · MCP Error Code Lookup
Latest research: State of MCP: October 2026
Frequently asked questions
Do I need OAuth for an internal server?
For a server reachable over a network, yes, or an equivalent gate. Hosts expect the OAuth discovery flow, so OAuth is the most compatible option. A local stdio server uses environment credentials instead.
JWT verification or token introspection?
JWT verification is fast and works offline once keys are cached. Introspection asks the provider about each token, which catches revoked tokens sooner. The Python SDK guide points to an IntrospectionTokenVerifier example in its repository.
What does the client need to support?
Discovery through Protected Resource Metadata, PKCE and the resource parameter are all client requirements in the spec. Host support varies, so check our client support matrix for the hosts your users need.
Can one server accept tokens from two providers?
It can, if your verifier tries each issuer and checks audience for each. Keep it simple unless you truly need it.
How do I log who did what?
Log token.subject, the tool name and its arguments on every call. That gives you an audit trail tied to a real user.











