Your MCP Server Is an OAuth 2.1 Resource Server: A Field Guide to the 2026-07-28 Auth Spec
The state of MCP authentication is a dumpster fire, and the data proves it. A study on arXiv:2605.22333 found that every single one of 119 tested OAuth-enabled MCP servers had at least one auth flaw 3. Only 8.5 percent of 5,205 OSS MCP servers use OAuth at all 5. And 40.55 percent of 7,973 live remote servers have no auth whatsoever 3.
The 2026-07-28 MCP authorization spec is the industry’s attempt to put out that fire. The core reframe: your MCP server is not an auth server. It’s an OAuth 2.1 resource server. That means you’re responsible for exactly three things: validating tokens, serving metadata, and issuing the right challenges. That’s it.
Let’s build it correctly.
The role model: resource server, not auth server
Under the 2026-07-28 spec, your MCP server implements RFC 9728 (Protected Resource Metadata) and behaves as an OAuth 2.1 resource server 1. You do not implement authorization endpoints, token issuance, or consent screens. Those belong to an external authorization server.
Your server’s job is to:
- Advertise its metadata via
/.well-known/oauth-protected-resource - Validate bearer tokens on every request
- Return
401with properWWW-Authenticatechallenges when tokens are missing or invalid
One critical caveat: this spec is HTTP-transport only. If you’re building a STDIO-based MCP server, none of this applies. The auth spec explicitly states STDIO transports SHOULD NOT follow this specification 1.
Audience binding is the load-bearing wall
The most important security property in this spec is audience binding. Clients MUST send the resource parameter (RFC 8707) in both authorization and token requests 1. Your server MUST validate that the token’s aud claim matches your server’s identifier.
Token passthrough is explicitly forbidden. If a client presents a token meant for another service, you reject it. Period. This principle matters in practice: the Asana MCP server bug was a tenant-isolation flaw that potentially exposed roughly 1,000 customer organizations — the kind of cross-boundary data leak that strict audience binding is designed to prevent 7.
Here’s the metadata endpoint your server must expose:
from fastapi import FastAPI
from fastapi.responses import JSONResponse
app = FastAPI()
@app.get("/.well-known/oauth-protected-resource")
async def protected_resource_metadata():
return JSONResponse({
"resource": "https://mcp.example.com",
"authorization_servers": ["https://auth.example.com"],
"scopes_supported": ["tools:read", "tools:write", "resources:read"],
"bearer_methods_supported": ["header"]
})
Client registration: CIMD replaces DCR
The 2026-07-28 spec deprecates Dynamic Client Registration (DCR) in favor of Client ID Metadata Documents (CIMD) 1. Why? Because DCR is a security nightmare. The arXiv study found that 96.6 percent of tested servers had DCR flaws 3. That’s not a rounding error; that’s a systemic failure.
CIMD is simpler: clients publish a static JSON document at a well-known URL describing their metadata. Your server fetches and caches it. No dynamic registration endpoint to abuse. The spec retains DCR for backwards compatibility but explicitly deprecates it — don’t build new DCR integrations 1.
Mix-up defense and token lifecycle
The 2026-07-28 revision adds mandatory mix-up defense. Clients MUST validate the iss parameter (RFC 9207) in authorization responses 1. PKCE with S256 is mandatory for all public clients. No exceptions.
For token lifecycle, the spec recommends:
- Short-lived access tokens (minutes, not hours)
- Refresh token rotation for public clients — every refresh issues a new refresh token and invalidates the old one
- Binding of refresh tokens to the client’s TLS certificate or client ID
The stakes are concrete: JFrog CVE-2025-6514 (CVSS 9.6) was a command injection RCE in mcp-remote that had over 437,000 downloads 8. That vulnerability allowed an attacker to execute arbitrary commands on the host running the MCP client by exploiting the client’s trust in a server-supplied authorization_endpoint. Validating metadata against known-good authorization servers — rather than blindly trusting server-announced endpoints — is part of the defense-in-depth this spec enables.
Least-privilege scopes and step-up auth
Your server advertises required scopes in the WWW-Authenticate header. When a token lacks sufficient scope, you return 403 with insufficient_scope 1. This triggers the client to perform step-up re-authorization.
Here’s the challenge flow:
from fastapi import Request, Response
from fastapi.responses import JSONResponse
import re
TOOL_SCOPE_MAP = {
"create_resource": ["tools:write"],
"list_resources": ["tools:read"],
"read_resource": ["resources:read"],
}
@app.middleware("http")
async def auth_middleware(request: Request, call_next):
if request.url.path == "/.well-known/oauth-protected-resource":
return await call_next(request)
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return Response(
status_code=401,
headers={
"WWW-Authenticate": (
'Bearer realm="mcp", '
'resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource", '
'scope="tools:read tools:write resources:read"'
)
}
)
token = auth_header[7:]
# Extract tool name from JSON-RPC payload for scope checking
# In production, validate token via introspection or local JWT validation
payload = await request.json() if request.method == "POST" else {}
tool_name = payload.get("params", {}).get("name", "")
required_scopes = TOOL_SCOPE_MAP.get(tool_name, [])
# Validate token and extract scopes (simplified — use your AS's introspection endpoint)
token_info = validate_token(token) # Returns dict with "scope" key or None
token_scopes = token_info.get("scope", "").split() if token_info else []
if required_scopes and not all(s in token_scopes for s in required_scopes):
return JSONResponse(
status_code=403,
content={"error": "insufficient_scope"},
headers={
"WWW-Authenticate": (
f'Bearer realm="mcp", '
f'resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource", '
f'scope="{" ".join(required_scopes)}"'
)
}
)
return await call_next(request)
Wiring it into the MCP Python SDK
The MCP Python SDK (v1.28+) provides TokenVerifier and AuthSettings to handle this cleanly. Here’s how you integrate with FastMCP:
from mcp.server.fastmcp import FastMCP
from mcp.server.auth import AuthSettings
from mcp.server.auth.provider import TokenVerifier, AccessToken
import jwt
from jwt import PyJWKClient
JWKS_URL = "https://auth.example.com/.well-known/jwks.json"
jwks_client = PyJWKClient(JWKS_URL)
class MyTokenVerifier(TokenVerifier):
async def verify_token(self, token: str) -> AccessToken | None:
"""Validate JWT and return AccessToken if valid, None otherwise."""
try:
signing_key = jwks_client.get_signing_key_from_jwt(token)
claims = jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
audience="https://mcp.example.com", # CRITICAL: audience check
issuer="https://auth.example.com",
options={"require": ["exp", "iss", "aud"]},
)
return AccessToken(
token=token,
scopes=claims.get("scope", "").split(),
expires_at=claims["exp"],
)
except jwt.PyJWTError:
return None
# Wire verifier into FastMCP via AuthSettings
auth_settings = AuthSettings(
issuer_url="https://auth.example.com",
resource_server_url="https://mcp.example.com",
required_scopes=["tools:read", "tools:write"],
)
mcp = FastMCP(
"My MCP Server",
token_verifier=MyTokenVerifier(),
auth=auth_settings,
)
@mcp.tool()
async def create_resource(name: str, data: dict) -> dict:
"""Create a new resource. Requires tools:write scope."""
return {"status": "created", "name": name}
@mcp.tool()
async def list_resources() -> dict:
"""List available resources. Requires tools:read scope."""
return {"resources": []}
The SDK auto-publishes the RFC 9728 metadata endpoint and handles 401/403 responses with proper WWW-Authenticate headers. Your tool functions receive the validated caller identity via get_access_token().
The audience validation you can’t skip
The single most important line in that code is audience="https://mcp.example.com". If you skip audience validation, you’re vulnerable to token reuse attacks across different MCP servers. The spec is unambiguous: clients MUST send the resource parameter, and servers MUST validate the aud claim 16.
Here’s the JWT validation logic in detail:
import jwt
from jwt import PyJWKClient
JWKS_URL = "https://auth.example.com/.well-known/jwks.json"
jwks_client = PyJWKClient(JWKS_URL)
def validate_token(token: str, audience: str, issuer: str) -> dict:
"""Decode and validate a JWT against the authorization server's JWKS."""
signing_key = jwks_client.get_signing_key_from_jwt(token)
claims = jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
audience=audience, # MUST match your resource identifier
issuer=issuer, # MUST match your authorization server
options={
"require": ["exp", "aud", "iss"],
}
)
return claims
The bottom line
Your MCP server is a resource server. It doesn’t authenticate users; it validates tokens. It doesn’t issue credentials; it challenges invalid ones. The 2026-07-28 spec gives you a clear, minimal contract: metadata endpoint, token validation with audience binding, and proper WWW-Authenticate challenges.
The alternative is what we have today: 40 percent of live remote MCP servers with zero auth, and 100 percent of OAuth-enabled servers with at least one flaw 3. That’s not acceptable for infrastructure that’s increasingly handling sensitive data and privileged operations.
The spec is your field guide. Implement it. Your users’ data depends on it.
Sources
📖 Related Reads
- ToolBrain — tool reviews, LLM comparisons, and AI workflow guides
- Hermes Tutorials — Hermes Agent setup, configuration, and advanced workflows
Cross-links automatically generated from NiteAgent.
← Back to all posts


