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_notesanddelete_everythingare 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-knowndiscovery, scope enforcement, SSRF-safe introspection.mcp-authflow-resourceis 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:

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-knowndiscovery 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:
- Client calls your MCP server with no
Authorizationheader. - Resource server returns
401with aWWW-Authenticateheader whoseresource_metadataparameter points atGET /.well-known/oauth-protected-resource(RFC 9728). - Client fetches that and learns the auth server's URL and supported scopes.
- Client fetches
GET /.well-known/oauth-authorization-serverat the auth server (RFC 8414) to discover the/register,/authorize,/token, and/introspectendpoints. - Client calls
POST /register(RFC 7591) with aredirect_urislist and scope request. Auth server issues aclient_id. For public clients (MCP GUIs), no client secret. - 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." - Auth server redirects back to the client's loopback URL (RFC 8252) with a short-lived
code. - Client exchanges the code at
POST /tokenwith its PKCE verifier. Auth server returns{"access_token": "…", "token_type": "bearer", "expires_in": 3600, "scope": "notes:read notes:write"}. - Client retries the original MCP call with
Authorization: Bearer <token>. - Resource server introspects the token at the auth server (RFC 7662). If
"active": trueand 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 StarletteJSONResponsewith the right status code andCache-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:
IntrospectionTokenVerifiercalls the auth server's/introspectwith SSRF protection baked in (is_safe_urlblocks HTTP-to-external-hosts unless you opt in).register_oauth_discovery_endpointswires up five.well-knownroutes: 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:9000with/register,/authorize,/token,/introspect, and an HTML consent page. - An MCP resource server at
http://localhost:9001/mcpwith five tools (list_notes,get_note,create_note,update_note,delete_note), each scopednotes:readornotes: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.
- Auth server:
brooksmcmillin/mcp-authflow(pip install mcp-authflow) - Resource server:
brooksmcmillin/mcp-authflow-resource(pip install mcp-authflow-resource) - End-to-end example:
brooksmcmillin/example-mcp-server
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.