You ship an MCP server. It runs fine over stdio in your editor. Then you deploy it to a URL, point Claude Desktop at it, and get this back:

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

The server's up. The endpoint responds. And the client just bounces off it. If you went looking for a fix, you found fragments: an Entra ID walkthrough, a .NET sample, someone's raw OAuth gist, a VS Code thread. None of them quite your stack, none of them the whole picture.

This is the whole picture for mcp server authentication in FastMCP (Python), from why that 401 happens to working code against a real client.

Why MCP auth is its own problem

The instinct is "add OAuth" and move on. That instinct is what produces the 401 above.

Two things are true at once, and they pull in different directions. First, since the 2025-06-18 MCP spec revision, your MCP server is a resource server only. It validates tokens someone else issued. It does not mint them. Second, an interactive client like Claude doesn't arrive holding a token. It arrives expecting your server to tell it where to go get one.

So auth on an MCP server is really two separate jobs, and the scattered guides you'll find almost never name the split:

Token verification is passive. A token shows up in the Authorization header, you check its signature, expiry, issuer, audience, and scopes, then you allow or reject. Good for service accounts, internal gateways, CI, anywhere the caller already knows how to get a token.

Remote OAuth is active. Your server runs the OAuth discovery and flow endpoints a client needs to log a user in from scratch. Required for Claude, Cursor, VS Code, any human-in-the-loop client.

Here's the trap. You reach for JWTVerifier because it's the first thing in the docs, you wire it up, and Claude Desktop still won't connect. That's not a bug. Token verification doesn't expose the OAuth endpoints the client is looking for. The 401 fires, the client probes for discovery metadata, finds nothing actionable, and gives up. The passive job was the wrong job for an interactive client.

Get the distinction right and the rest of the wiring is mechanical.

The actual FastMCP auth flow

Auth wires in one place: the auth= argument on the FastMCP() constructor. Everything else is which object you pass to it.

For the passive case, a bearer token lands on a remote request, and the verifier does this:

from fastmcp import FastMCP
from fastmcp.server.auth.providers.jwt import JWTVerifier
 
verifier = JWTVerifier(
    jwks_uri="https://auth.yourcompany.com/.well-known/jwks.json",
    issuer="https://auth.yourcompany.com",
    audience="mcp-production-api",
)
 
mcp = FastMCP(name="Protected API", auth=verifier)

That's a complete protected server. FastMCP pulls the token off the Authorization header, fetches your IdP's public keys from the JWKS endpoint (with rotation handled for you), and checks the signature plus exp, iss against your issuer, and aud against your audience. Wrong issuer or wrong audience, the token's rejected even if the signature is perfect.

Token verification landed in FastMCP 2.11.0 and the surface is stable into v3. If your tokens are opaque rather than JWTs, swap the verifier for introspection against your auth server (RFC 7662):

from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier
 
verifier = IntrospectionTokenVerifier(
    introspection_url="https://auth.yourcompany.com/oauth/introspect",
    client_id="mcp-resource-server",
    client_secret="your-client-secret",
    required_scopes=["api:read", "api:write"],
)

Same wiring, auth=verifier. The difference is JWTs validate locally off cached keys, while opaque tokens cost a network call per request but can be revoked instantly server-side. Pick based on whether you need instant revocation more than you need latency.

Token handling specifics

A few things decide whether your validation is actually sound.

Signature source. JWTVerifier takes three forms. A jwks_uri for production, where keys rotate and you never touch config. A static public_key PEM for fixed-key setups. Or, despite the parameter name, a shared secret in public_key when you're running HMAC (algorithm="HS256") between services you control. The name is public_key for backwards compatibility even when the value is symmetric.

Audience validation matters more than it looks. The audience check is what stops a token minted for another service in your org from being replayed against your MCP server. Skip it and any valid token from your IdP opens your server. This is the quiet one that bites in multi-service shops.

Scopes are authorization, not authentication. A valid token with insufficient scope doesn't 401, it 403s, with a WWW-Authenticate header carrying error="insufficient_scope" and the scope it wanted. Worth distinguishing in your own logs, because a 401 means "who are you" and a 403 means "I know who you are, you can't do this."

Your server stores nothing. Validation is stateless. There's no session, no token store on the resource server. The token is the proof, every request, self-contained.

What breaks in practice: the DCR cliff

If you want the picture first, here's the broker pattern drawn in one diagram. It's the same story this section tells, just in arrows.

Here's where most production setups fall over, and it has a name worth knowing: Dynamic Client Registration.

The MCP spec expects clients to register themselves with your auth server automatically, on the fly, no human in a developer console. That's DCR. Modern IdPs built for this, like WorkOS AuthKit and Descope, support it. For those, RemoteAuthProvider is the whole answer:

from fastmcp.server.auth.providers.workos import AuthKitProvider
 
auth = AuthKitProvider(
    authkit_domain="https://your-project.authkit.app",
    base_url="https://your-mcp-server.com",
)
 
mcp = FastMCP(name="Protected Application", auth=auth)

That configures JWT validation against WorkOS's keys and serves the OAuth discovery metadata, and clients register themselves with zero manual setup.

Now the cliff. GitHub, Google, Azure, AWS, and Discord do not support DCR. They want you to register an app by hand in their console and hand you fixed credentials. If you point RemoteAuthProvider at one of them, the client tries to register dynamically, there's nothing to register against, and you get 401 loops and redirect-URI mismatches. This is a common way a "working" auth setup fails against a real provider.

The bridge is OAuthProxy. It presents a DCR-compliant face to the MCP client while using your one fixed app credential upstream. When a client tries to register, the proxy hands back your pre-registered credentials and manages the callback forwarding underneath.

For the common providers there's a convenience subclass so you don't wire the endpoints by hand:

import os
from fastmcp import FastMCP
from fastmcp.server.auth.providers.github import GitHubProvider
 
auth = GitHubProvider(
    client_id=os.environ["GITHUB_CLIENT_ID"],
    client_secret=os.environ["GITHUB_CLIENT_SECRET"],
    base_url="https://your-server.com",
)
 
mcp = FastMCP(name="My Server", auth=auth)

GoogleProvider and AzureProvider follow the same shape. Under the hood they're all OAuthProxy, which arrived in FastMCP 2.12.0 specifically to close this gap.

One thing the proxy does that's easy to miss: it doesn't forward the upstream provider's token to the client. It issues its own FastMCP JWT and keeps the GitHub or Google token encrypted server-side, referenced by an ID inside the JWT. That means a token your client holds can't be turned around and used directly against GitHub, which is the point. It also means production needs a stable jwt_signing_key and a real client_storage backend (Redis, DynamoDB), because the default disk store and keyring setup is development-only and tokens won't survive a restart on Linux otherwise.

If you wire OAuthProxy to a non-DCR provider and connections work but feel insecure-by-default, check that require_authorization_consent is left at its default True. The proxy runs a consent screen to defend against confused-deputy attacks, where a malicious client rides a previously-approved OAuth app to get a code issued under your identity. Turning consent off removes that layer.

Local versus remote: auth is a transport concern

Whether your server needs auth at all depends on the transport. This part reframes everything above and is rarely stated in the guides.

stdio runs your server as a local subprocess over stdin/stdout, under your own user account. There's no network surface and no other user to authenticate. The spec neither requires nor enforces auth here. This is why your server "worked fine" in your editor: stdio gave you nothing to authenticate, so the absence of auth was invisible.

HTTP / SSE / Streamable HTTP is remote. There's a network boundary and potentially other users, and the spec requires auth. The moment you moved from "runs in my editor" to "runs at a URL," the auth requirement kicked in. That's where the 401 came from.

So the rule is short: stdio, skip auth. HTTP, you need a verifier or a proxy. Same server code, the transport decides whether auth is even in play.

Putting the failure back together

Back to the opening 401. With the split in hand, you can read it. The client hit your HTTP endpoint, got a 401 with a WWW-Authenticate pointing at resource_metadata, and went looking for OAuth discovery. If you'd configured only a JWTVerifier, there were no flow endpoints to find, and the connection died there. The fix wasn't more OAuth config, it was the right kind: a RemoteAuthProvider for a DCR provider, or an OAuthProxy subclass for GitHub-class providers.

Two real client gotchas to bank, both from Claude's own connector behaviour:

Loopback redirects ignore the port. Claude Code uses an ephemeral loopback redirect, a fresh http://localhost:<random>/callback per session. Your auth server has to accept http://localhost/callback and http://127.0.0.1/callback with the port component ignored, or every session breaks on redirect mismatch. In FastMCP:

allowed_client_redirect_uris=["http://localhost:*", "http://127.0.0.1:*"]

No static API keys, no machine-to-machine. Claude's connector auth disallows a pure client_credentials grant and user-pasted static bearer tokens. Every connection goes through user consent, and PKCE with S256 is required. So "just let users paste an API key" isn't on the table for a Claude-facing server. If that's the model you wanted, you're implementing OAuth whether you like it or not.

The short version

  • Decide the job first. Passive token check, or active OAuth flow? Pick wrong and the client won't connect.
  • auth= on FastMCP() is the one wiring point. JWTVerifier or IntrospectionTokenVerifier for verification, RemoteAuthProvider for DCR providers, OAuthProxy (or GitHubProvider / GoogleProvider / AzureProvider) for everything else.
  • The DCR cliff is the production killer. GitHub, Google, Azure, AWS, Discord need the proxy, not RemoteAuthProvider.
  • Validate audience, not just signature. A token for another service is not a token for yours.
  • stdio needs no auth, HTTP does. The transport decides.

That's the full version of mcp server authentication for FastMCP.


Code targets FastMCP 3.x (PrefectHQ/jlowin, gofastmcp.com). FastMCP moves fast, so pin and re-check signatures against the live docs before you ship.