Menu

AI

How to Add OAuth to a Remote MCP Server (JWT Verification, Python)

Protect a remote MCP server with OAuth: pick an authorization server, verify JWTs against its JWKS, check audience and scopes, and publish discovery metadata.

By: Irina ShvayaNovember 2, 2026

How to Add OAuth to a Remote MCP Server (JWT Verification, Python)

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 None on 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:

OptionWhat its docs sayNotes
WorkOS AuthKitTokens carry an aud that "matches the requested resource" once you add a Resource IndicatorCIMD is off by default; enable it in the dashboard
Auth0Offers "standards-aligned discovery and registration" for MCP, per its Auth for MCP pageAlso offers token exchange for calling your own APIs
Cloudflare Workers OAuth ProviderAn authorization server Worker in front of an MCP Worker, per our course lessonGood if you host on Workers
Your existing IdPAny OAuth 2.1 provider that issues JWTs with an audienceCheck 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:

  1. Audience control. Can tokens carry your MCP server's URL as aud? WorkOS says that with no Resource Indicators configured, aud falls back to the client ID, which would fail an audience check.
  2. Client registration. The spec says authorization servers and clients "SHOULD support" CIMD, and "MAY support" Dynamic Client Registration.
  3. 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. PyJWKClient fetches the provider's public keys and picks the one named in the token's kid header. 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=RESOURCE rejects 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_url names your authorization server. It appears in the discovery document.
  • resource_server_url is this server's public MCP URL.
  • required_scopes must be on every token.
  • validate_token_resource=True refuses tokens whose resource does not match. The guide says leaving it unset warns and behaves as False, and that 3.0 makes True the 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>":

Tokenmy_orderscancel_order
Correct audience, orders:read"Orders for user_alice: none yet."Tool error: needs orders:write
Correct audience, read and writeSuccess"Cancelled 1001 for user_bob."
Audience https://other-api.example.comHTTP 401HTTP 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:

  1. 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 the resource parameter, and that the canonical form has no trailing slash unless the slash is meaningful.
  2. Define scopes, such as orders:read and orders:write.
  3. Enable CIMD if your provider supports it, and DCR only if a host you need still requires it.
  4. 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

  1. 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.
  2. Trailing slash mismatch. https://mcp.example.com/mcp/ and https://mcp.example.com/mcp are different audiences to a JWT library.
  3. Default aud. Some providers put the client ID in aud unless you configure a resource. Decode a real token and look.
  4. Building your own authorization server. The SDK guides point to a dedicated identity provider instead.
  5. Old imports. mcp.server.fastmcp is gone in SDK v2; use from 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.

You Might Also like to Read

Get a FREE Website Audit

Enter your website and get a 20–30 page SEO, GEO, AEO & CRO audit within 24 hours. Completely free.

Don't have a site yet? Get in touch →

Ready to speak with a marketing expert?
Give us a call! (916) 866-7893

Websites Launched
1407
Websites Launched
Marketing Experts
29
Marketing Experts
Hours of Dedicated Work
204533
Hours of Dedicated Work
Irina S.Benjamin G.Jeanette M.David G.