If you've deployed an MCP server past your own laptop, you've had the auth conversation with yourself.

Option A: expose it with no auth and pretend nobody will find /mcp on a public hostname. Option B: a shared static API key baked into every client config. Option C: "allow localhost and pray."

None of those are auth. At best they're obfuscation that survives until someone's laptop gets compromised, the secret leaks into a Slack paste, or your tenancy model grows beyond one. And the MCP servers I run aren't read-only. They can delete tasks, create PRs, and write to databases. I want real tokens, real scopes, real rotation, real audit, and I want the protocol the MCP clients already speak.

So I wrote mcp-authflow and mcp-authflow-resource. Two MIT-licensed Python packages on PyPI plus a runnable example-mcp-server that boots the whole flow with docker compose up. GitHub, PyPI, and import are all mcp-authflow (and -resource).

Both packages started from the MCP Python SDK's reference auth-server example and grew the missing production pieces around it: a real database, rate limiting, error handling, scope enforcement, SSRF-safe introspection, and the discovery wiring MCP clients actually probe.

This post is the protocol tour: what's wrong with the status quo, why OAuth 2.0 is the right shape for MCP even if you've never loved it elsewhere, what the two packages do, and three non-obvious things I paid for in production so you don't have to.

The Gap: MCP Without Auth

The current state of an average self-hosted MCP server config:

{
  "mcpServers": {
    "my-server": {
      "url": "http://localhost:9001/mcp",
      "headers": { "X-API-Key": "super-secret-please-dont-leak" }
    }
  }
}

Some problems with this:

  • Static, undifferentiated credentials. Every client, every session, every user shares one secret. Revoke it and everyone is down.
  • No scoping. If the key works, it works for every tool. list_notes and delete_everything are guarded identically.
  • No rotation story. Rotating means coordinating a secret swap across every config file. So it doesn't happen.
  • No audit. Logs say "a request from somewhere used the key." You can't tell who, or from which session.
  • No revocation granularity. You can kill the key. You can't kill a specific agent's access while keeping yours.

These are not hypotheticals. The moment an MCP server can take destructive actions, you want "Claude on laptop A revoked; Claude on laptop B unaffected; service account C still fine." That's the job description for OAuth.

Why OAuth 2.0, and Not a Homebrew Token Scheme

I know the shape of the objection: OAuth is enterprisey, the spec is twelve RFCs in a trench coat, and you just want a bearer token. Two reasons I think it's worth the bytes anyway for MCP specifically.

1. Clients already speak it. The MCP SDKs in Claude Desktop, Claude Code, and increasingly other clients already probe .well-known/oauth-protected-resource and run the authorization-code-with-PKCE dance when they see one. Stand up an RFC-compliant auth server and those clients Just Work. Invent a scheme, and every client needs a custom path.

2. The individual RFCs are narrow and useful. None of these are big specs on their own:

RFC What it gives you
6749 The core authorization-code + client-credentials flows
6750 Authorization: Bearer <token>
7636 PKCE: no client secret needed for public clients
7591 Dynamic client registration, where clients register themselves
7662 Token introspection (resource servers ask "is this token valid?")
8252 OAuth for native apps (loopback redirects, ephemeral ports)
8414 Auth server metadata discovery
8707 Resource indicators that bind tokens to a specific resource server
9728 Protected resource metadata ("here's where my auth server lives")

Pick the subset that matches your setup. mcp-authflow implements the auth-server half; mcp-authflow-resource implements the resource-server half. You get rotation, scopes, introspection, dynamic registration, and discovery without writing any of it.

What about FastMCP, the MCP SDK example, or running Auth0?

What I looked at first:

  • MCP-ecosystem auth helpers. The MCP Python SDK's reference auth server implements the protocol but is structured as an example, not a library: in-memory storage only, minimal error handling, no rate limiting. FastMCP's bearer-auth helpers verify a pre-issued JWT but don't stand up an issuer.
  • Auth0, Keycloak, Authentik. Real OAuth issuers, and a fine choice if you already run one. The work that remains on the resource-server side is the same: RFC 9728 protected-resource metadata, MCP-aware .well-known discovery, scope enforcement, SSRF-safe introspection. mcp-authflow-resource is designed to point at any RFC 7662 / RFC 8414 issuer, so you can keep the one you have and add the MCP half.

If your stack already has an issuer, you might only need mcp-authflow-resource. The two packages are independent.

The Shape: Auth Server vs. Resource Server

The split matters, so here it is plainly:

Architecture diagram. An MCP client makes register/authorize calls to the Auth Server (which stores tokens in PostgreSQL) and MCP + Bearer calls to the Resource Server. The Resource Server wraps your tools and introspects each bearer token against the Auth Server.

Text version of the diagram
                                ┌──────────────────┐
                                │    PostgreSQL    │
                                │  (token storage) │
                                └───────▲──────────┘
                                         │
┌──────────────┐  register/authorize ┌───┴───────────┐  introspect    ┌──────────────────┐
│  MCP Client  │ ────────────────────▶│  Auth Server  │ ◄──────────────│ Resource Server  │
│ (Claude etc.)│                     │    :9000      │                │  (MCP) :9001     │
│              │  MCP + Bearer       │               │                │                  │
│              │ ─────────────────────────────────────────────────────▶ │  Your Tools      │
└──────────────┘                     └───────────────┘                └──────────────────┘

Two servers, different jobs:

  • Auth server (mcp-authflow) issues, stores, and introspects tokens. It owns client registration, consent UI, token lifecycle, rate limiting, and signing material. It has a database. It rarely changes after you stand it up.
  • Resource server (mcp-authflow-resource) wraps your actual MCP server. It verifies bearer tokens on every request, enforces scopes, and publishes .well-known discovery metadata. It has no user state and no database; it's a thin shell around your tools.

That split also maps to the deployment story: many MCP servers, one auth server. A single issuer can protect every MCP server you run, and each resource server introspects tokens against the same upstream.

The Protocol Flow

Concretely, from the moment a client picks up your URL for the first time:

  1. Client calls your MCP server with no Authorization header.
  2. Resource server returns 401 with a WWW-Authenticate header whose resource_metadata parameter points at GET /.well-known/oauth-protected-resource (RFC 9728).
  3. Client fetches that and learns the auth server's URL and supported scopes.
  4. Client fetches GET /.well-known/oauth-authorization-server at the auth server (RFC 8414) to discover the /register, /authorize, /token, and /introspect endpoints.
  5. Client calls POST /register (RFC 7591) with a redirect_uris list and scope request. Auth server issues a client_id. For public clients (MCP GUIs), no client secret.
  6. Client opens the user's browser at /authorize?…code_challenge=…&code_challenge_method=S256 (RFC 7636). Auth server renders a consent page. User clicks "Approve."
  7. Auth server redirects back to the client's loopback URL (RFC 8252) with a short-lived code.
  8. Client exchanges the code at POST /token with its PKCE verifier. Auth server returns {"access_token": "…", "token_type": "bearer", "expires_in": 3600, "scope": "notes:read notes:write"}.
  9. Client retries the original MCP call with Authorization: Bearer <token>.
  10. Resource server introspects the token at the auth server (RFC 7662). If "active": true and scopes cover the requested tool, the call runs.

Everything after step 9 stays in the hot path: bearer header → introspection → tool. The dance from steps 1 to 8 happens once per client, maybe once more on token expiry.

What's in the Box

mcp-authflow: the auth-server half

pip install mcp-authflow             # Memory token storage (dev/test)
pip install mcp-authflow[postgres]   # PostgreSQL-backed, for prod

What you use from it:

  • Token storage (MemoryTokenStorage, PostgresTokenStorage) with a stable async interface: store_token, load_token, delete_token, refresh-token parallels, cleanup_expired_tokens.
  • RFC 6749 error responses (invalid_request, invalid_client, invalid_grant, invalid_scope, rate_limit_exceeded, …). Each returns a Starlette JSONResponse with the right status code and Cache-Control: no-store.
  • Sliding-window rate limiting per client. Wrap the token endpoint and you get backpressure for free.
  • Input validation for client IDs, scopes, and PKCE parameters. Small, but it's the boring stuff that otherwise becomes a CVE.
  • CORS helpers with explicit origin allowlisting, because wildcard-CORS on a token endpoint is how you lose.

You still write the actual OAuth endpoints (they're ~80 lines of Starlette routes), but you compose them from these primitives instead of inventing them.

mcp-authflow-resource: the resource-server half

pip install mcp-authflow-resource

This is the package you drop into any existing MCP server:

from mcp.server.fastmcp.server import FastMCP
from mcp.server.auth.settings import AuthSettings
from pydantic import AnyHttpUrl

from mcp_authflow_resource import (
    IntrospectionTokenVerifier,
    register_oauth_discovery_endpoints,
)

RESOURCE_URL = "https://mcp.example.com"
AUTH_URL = "https://auth.example.com"

verifier = IntrospectionTokenVerifier(
    introspection_endpoint=f"{AUTH_URL}/introspect",
    server_url=RESOURCE_URL,
)

app = FastMCP(
    name="My Protected MCP Server",
    token_verifier=verifier,
    auth=AuthSettings(
        issuer_url=AnyHttpUrl(AUTH_URL),
        required_scopes=["read"],
        resource_server_url=AnyHttpUrl(RESOURCE_URL),
    ),
)

register_oauth_discovery_endpoints(
    app,
    server_url=RESOURCE_URL,
    auth_server_public_url=AUTH_URL,
    scopes=["read"],
)

@app.tool()
async def hello(name: str) -> str:
    return f"Hello, {name}!"

That's the whole protection layer. Clients now have to present a valid bearer with the read scope to call any tool. Under the hood:

  • IntrospectionTokenVerifier calls the auth server's /introspect with SSRF protection baked in (is_safe_url blocks HTTP-to-external-hosts unless you opt in).
  • register_oauth_discovery_endpoints wires up five .well-known routes: RFC 9728 protected-resource metadata (root + a path-scoped /mcp/ variant for multi-app deployments), RFC 8414 auth-server metadata (root + path-scoped), and an OIDC discovery alias for clients that insist on OIDC.

example-mcp-server: everything composed

The example repo stitches the two packages into a runnable reference: PostgreSQL + auth server on :9000 + resource server with a notes CRUD API on :9001.

git clone https://github.com/brooksmcmillin/example-mcp-server
cd example-mcp-server
docker compose up

Thirty seconds later you have:

  • An OAuth 2.0 auth server at http://localhost:9000 with /register, /authorize, /token, /introspect, and an HTML consent page.
  • An MCP resource server at http://localhost:9001/mcp with five tools (list_notes, get_note, create_note, update_note, delete_note), each scoped notes:read or notes:write.
  • Discovery wiring so clients auto-configure.

Point Claude Code at it:

{
  "mcpServers": {
    "notes": {
      "type": "streamable-http",
      "url": "http://localhost:9001/mcp"
    }
  }
}

First tool call gets 401 → DCR → browser opens → click Approve → tokens flow → tool runs. The README walks the manual curl version for anyone wanting to watch every step.

Three Things I Paid For So You Don't Have To

These are non-obvious failure modes I hit in production. The framework has been protecting four MCP servers in my own infrastructure for two months, all introspecting tokens against a single auth server. All three are handled in the packages; they're worth knowing about if you're thinking of rolling your own.

1. .well-known endpoints must return JSON, not text

When Claude Code's MCP SDK connects to an HTTP MCP server, it probes GET /.well-known/oauth-authorization-server and GET /.well-known/oauth-protected-resource before sending any MCP requests. If those routes aren't defined, Starlette's default Not Found response is plain text. The SDK's JSON parser throws and the entire connection fails, with no useful error surfaced to the user.

The fix is a two-line response:

async def _well_known_not_found(request: Request) -> Response:
    return Response(content="{}", status_code=404, media_type="application/json")

register_oauth_discovery_endpoints handles this automatically; even the endpoints you don't use return JSON 404s instead of text. (I wrote about the surrounding context when I rebuilt my local proxy; the same failure mode applies to any auth-aware MCP server.)

2. Loopback redirects need ephemeral ports

MCP clients that run as native apps (Claude Code, Claude Desktop) spin up a short-lived local HTTP listener on an ephemeral port to catch the authorization-code redirect. RFC 8252 §7.3 says loopback redirect URIs should ignore the port in the registration check. Register http://127.0.0.1/callback once, accept any port at authorization time.

If you strictly match the port, the client's redirect lands on a URI your server has never seen, auth fails, and the user gets a cryptic error. mcp-authflow's client registration follows the RFC: 127.0.0.1 and localhost redirects match any port by default.

3. Introspection vs. JWT: pick deliberately

The most common production question: "why introspection instead of signed JWTs?"

  • JWTs are stateless. The resource server verifies a signature and trusts the claims. Fast, but revocation is hard: you're stuck with short expiries or a revocation list that you may as well have called a database.
  • Introspection (RFC 7662) asks the auth server on every call. Slower (one extra hop), but revocation is instant; delete the token row and every subsequent call gets "active": false.

For MCP specifically, introspection is almost always the right default. Tokens are usually short-lived (one hour), tools are interactive not bulk, the extra latency is invisible next to the LLM call itself, and the revocation story is the one you'll actually exercise: "laptop stolen, kill Claude's tokens, keep the service account alive." mcp-authflow-resource's IntrospectionTokenVerifier handles the transport with SSRF protection built in.

If your profile is different (very short-lived tokens, extreme fan-out, no need for revocation), swap in a JWT verifier. The architecture is deliberately swappable at that point.

What's Not in Here (Yet)

This is a 0.x release with deliberate scope limits:

  • No admin UI. Client registration, consent, and token introspection have endpoints. There is no dashboard. You manage the database directly.
  • No roles or RBAC hierarchy. Scopes are flat strings. Map roles to scopes in your application, or layer something on top.
  • No SCIM, no multi-tenant provisioning. This is a single-tenant issuer suitable for your own MCP fleet, not a SaaS auth product.
  • No upstream SSO. The auth server is the source of truth for users in the example. Bring your own identity provider on top if you need SAML or OIDC federation.

Most of these limits are deliberate. The packages exist to solve the MCP-specific problem, not to replace Auth0.

Try It

The quickest way to evaluate is the example server:

git clone https://github.com/brooksmcmillin/example-mcp-server
cd example-mcp-server
docker compose up

Then wire Claude Code or Claude Desktop at http://localhost:9001/mcp and watch a full OAuth dance happen the first time a tool is called.

Both packages target Python 3.11+, are async-first on top of Starlette and the MCP SDK, and are MIT-licensed. The test suites cover the protocol behaviors (188 tests on the auth framework, 164 on the resource framework, run across 3.11 / 3.12 / 3.13 in CI). Issues and PRs welcome; I'm especially interested in integrations with other MCP clients and edge cases around discovery. For general questions, Discussions on mcp-authflow is the lower-friction venue.

If this is useful to you, starring helps me gauge whether to invest more here.


This post is a companion to Building Secure Agentic Systems: The Six Layers. That post covers what to secure across the agent stack; this one covers the identity layer concretely. If you're fighting MCP connectivity before auth, The MCP stdio Problem covers the transport plumbing that has to work first.