← Back to Blog

Lock Down Your Remote MCP Server with OAuth 2.1

Your remote MCP server takes one bearer token, and it is the same token for every client. That token is the entire security boundary. Anyone who reads it from a config file, a log, or a screenshot in a support ticket can call every tool you expose, and you cannot say which user did what.

The MCP authorization spec has a fix for this, and it does not require you to build a login system. A remote MCP server is an OAuth 2.1 resource server: it validates tokens that another system issues, and it never sees a password. The hard part is not the concept. The hard part is the handful of MUSTs the spec puts on the resource-server side, which are the ones people skip.

This guide walks the server-side checklist with FastMCP 2.11+ and shows the raw HTTP shapes, so the same steps apply if you use the TypeScript SDK or write the server yourself.

What you need

  • Python 3.10+ and FastMCP 2.11 or newer (pip install "fastmcp>=2.11")
  • An OAuth 2.1 authorization server: Keycloak, Auth0, WorkOS AuthKit, or any OIDC provider you already run
  • A public HTTPS URL for the server. Every authorization endpoint must be HTTPS, and redirect URIs must be HTTPS or localhost.
  • MCP Inspector for testing (npx @modelcontextprotocol/inspector)

Pick the transport first

The spec is blunt about this split. STDIO servers SHOULD NOT use this flow and should read credentials from the environment. HTTP servers SHOULD conform to it. If your server runs as a local subprocess inside someone's editor, an API key from an env var is the right answer and this article does not apply.

The moment the server is reachable over Streamable HTTP by clients you do not control, the env-var model breaks. You cannot hand one shared secret to every user, and you cannot revoke it for a single person without breaking everyone.

Know the three roles

Getting the roles right is most of the design.

  • MCP server: the OAuth 2.1 resource server. It validates tokens and serves tools. It never issues tokens.
  • MCP client: the OAuth 2.1 client, acting on behalf of a user.
  • Authorization server: the system that authenticates the user and mints tokens. It can be yours or an external IdP.

If you start rendering a login form inside the MCP server, you have merged the resource server with the authorization server and you are about to reimplement OAuth badly. Leave login to the IdP.

Step 1: Validate tokens, not just presence

The most important check is audience validation. A signed token is not a valid token for your server. If your server accepts any token whose signature it can verify, a token minted for a completely different service walks straight in. The spec makes this a MUST: the server has to confirm the token was issued specifically for it.

With FastMCP, add a verifier to the server:

# server.py
import os
from fastmcp import FastMCP
from fastmcp.server.auth.providers.jwt import JWTVerifier

verifier = JWTVerifier(
    jwks_uri=os.environ["JWKS_URI"],        # https://auth.example.com/.well-known/jwks.json
    issuer=os.environ["TOKEN_ISSUER"],      # https://auth.example.com
    audience=os.environ["TOKEN_AUDIENCE"],  # this server's resource URI
    required_scopes=["files:read"],
)

mcp = FastMCP(name="Files Server", auth=verifier)

@mcp.tool
def read_note(path: str) -> str:
    return open(path).read()

JWTVerifier fetches the public keys from the JWKS endpoint and checks the signature, iss, aud, and exp on every request. Keys rotate without a config change because the verifier re-reads the JWKS endpoint. If your provider hands out opaque tokens instead of JWTs, FastMCP ships an introspection verifier that calls the provider's RFC 7662 introspection endpoint, at the cost of a network call per request.

Two requirements from the spec are worth enforcing in code review. The token travels only in the Authorization: Bearer header, on every request, and it must never ride in a query string. A token that is valid but lacks the scope for an operation gets a 403, not a 401.

Step 2: Publish Protected Resource Metadata

This is the piece that makes the whole thing work with zero client configuration. An MCP client that has nothing but your URL starts by sending a request with no token. Your server answers 401 with a WWW-Authenticate header pointing at your Protected Resource Metadata document (RFC 9728):

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
                         scope="files:read"

The client fetches that URL and gets a small JSON document:

{
  "resource": "https://mcp.example.com/mcp",
  "authorization_servers": ["https://auth.example.com"],
  "scopes_supported": ["files:read", "files:write"],
  "bearer_methods_supported": ["header"]
}

resource is this server's canonical URI, and authorization_servers names the IdP that can issue tokens for it. Publishing this document is a MUST for MCP servers. Without it, a bare 401 tells the client it needs a token but not where to get one, which pushes per-server configuration onto every user.

FastMCP's RemoteAuthProvider wires up this endpoint for you with a DCR-capable IdP. A plain JWTVerifier gives you token validation without the OAuth discovery endpoints, which is fine for internal services whose clients already know how to get a token, but not enough for a public server.

Watch the path rule, because it trips people. The well-known URI is built by inserting /.well-known/oauth-protected-resource after the scheme and authority, and for a path-based server you insert it before the path. https://mcp.example.com/server/mcp is served at https://mcp.example.com/.well-known/oauth-protected-resource/server/mcp. Use your framework's helper instead of building the string by hand.

Step 3: Choose a client registration path

A client needs a client ID before it can run the authorization flow. The 2026-07-28 spec revision changed the priority order, and this is where tutorials written earlier mislead:

  1. Client ID Metadata Documents. The client hosts a JSON document at an HTTPS URL and uses that URL as its client_id. A SHOULD in the current spec.
  2. Pre-registration. You hand the client fixed credentials.
  3. Dynamic Client Registration (RFC 7591). Still works, now marked deprecated and kept for authorization servers that cannot do CIMD.

Where your IdP supports DCR or CIMD, FastMCP's RemoteAuthProvider gives you the automated flow. GitHub, Google, and Azure do not support DCR and require manual app registration. For those, OAuthProxy presents a DCR-compatible surface to MCP clients while using your fixed credentials upstream. That is the difference between "works with one click" and "works".

Step 4: Do not pass the token through

The confused-deputy problem lives here. If your MCP server calls an upstream API, say a Git host behind a git tool, it is a separate OAuth client toward that API and needs its own token from the upstream authorization server. Forwarding the client's token upstream is a MUST NOT in the spec. The upstream trusts the forwarded token as if it came from your server, and now one compromised client can act with your server's reach while the audit trail points at the wrong party.

Step 5: Handle scope shortfalls with step-up

Grant the minimum scope up front and grow it later. When a valid token lacks the scope for an operation, answer 403 with the missing scope, not 401:

HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope",
                         scope="files:write",
                         resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
                         error_description="File write permission required for this operation"

The client re-authorizes with the union of its previous scopes and the new one, then retries. Put the required scope on the initial 401 challenge too, so clients ask for exactly what they need. Splitting files:read from files:write is what stops a summarizer agent from quietly deleting things, and clients are expected to cap and track step-up attempts so a broken loop cannot spin forever.

Test it with the Inspector

Run the server, then point the Inspector at it:

npx @modelcontextprotocol/inspector
# enter https://mcp.example.com/mcp in the UI

You should see the 401, then the discovery hop to your IdP, then the consent screen. A common failure is a host that only accepts a URL and a headers map, which stops at the browser step. That is a client limitation, not a server bug, and it is exactly why the metadata endpoint matters: conforming hosts run the flow with nothing but your URL.

Verify the two failure cases by hand:

# no token -> 401 with WWW-Authenticate
curl -i -X POST https://mcp.example.com/mcp \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

# protected resource metadata
curl -s https://mcp.example.com/.well-known/oauth-protected-resource | jq

When to use this vs the alternatives

Approach Use when Watch out for
Env-var API key STDIO servers, internal service-to-service One key is one boundary, no per-user audit
OAuth 2.1 with JWT validation Public remote servers with an existing IdP Audience validation is still required, a valid signature alone is not enough
OAuth 2.1 with introspection The provider issues opaque tokens A network call per request, so pool your HTTP client
Full OAuth server inside the MCP server Air-gapped or unusual compliance needs You now own login UI, token storage, revocation, and the CVEs

Next steps

Reach for MultiAuth when one server has to accept both interactive clients and machine tokens: it composes an OAuth proxy with a JWT verifier and takes the first source that validates. Add structured audit logging of validator decisions so refusals become a signal instead of a stack trace nobody reads. Read the security best practices page next to the authorization page, because token passthrough and audience validation are where the real incidents start.

References

Need Help Implementing This?

I help teams design and build scalable cloud infrastructure, DevOps pipelines, and production-grade systems.

Book a Free Consultation