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 :. Only 8.5 percent of 5,205 OSS MCP servers use OAuth at all :. And 40.55 percent of 7,973 live remote servers have no auth whatsoever :.

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 ::. You do not implement authorization endpoints, token issuance, or consent screens. Those belong to an external authorization server.

Your server’s job is to:

  1. Advertise its metadata via /.well-known/oauth-protected-resource
  2. Validate bearer tokens on every request
  3. Return 401 with proper WWW-Authenticate challenges 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 ::.

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 ::. 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 :.

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) ::. Why? Because DCR is a security nightmare. The arXiv study found that 96.6 percent of tested servers had DCR flaws :. 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 :.

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 ::. 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 :. 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 ::. 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 acrosinst 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 [:](https://arxiv.org/abs/2605.22333). 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

[:](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization): https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization
[:](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/security-considerations): https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/security-considerations
[:](https://arxiv.org/abs/2605.22333): https://arxiv.org/abs/2605.22333
[:](https://securityboulevard.com/2026/08/how-mcp-authorization-actually-works-oauth-2-1-resource-servers-and-resource-indicators/): https://securityboulevard.com/2026/08/how-mcp-authorization-actually-works-oauth-2-1-resource-servers-and-resource-indicators/
[:](https://devtoollab.com/blog/mcp-server-authentication-oauth-guide-2026): https://devtoollab.com/blog/mcp-server-authentication-oauth-guide-2026
[:](https://workos.com/blog/mcp-2026-spec-agent-authentication): https://workos.com/blog/mcp-2026-spec-agent-authentication
[:](https://www.theregister.com/2025/06/18/asana_mcp_server_bug/): https://www.theregister.com/2025/06/18/asana_mcp_server_bug/
[:](https://research.jfrog.com/vulnerabilities/mcp-remote-command-injection-rce-jfsa-2025-001290844/): https://research.jfrog.com/vulnerabilities/mcp-remote-command-injection-rce-jfsa-2025-001290844/

<!-- crosslinks -->

## 📖 Related Reads

- **[ToolBrain](https://toolbrain.net/)** — tool reviews, LLM comparisons, and AI workflow guides
- **[Hermes Tutorials](https://hermes-tutorials.dev/)** — Hermes Agent setup, configuration, and advanced workflows

*Cross-links automatically generated from NiteAgent.*
← Back to all posts