Blog · Authorization · MCP07

How to add OAuth to an MCP server the way the spec expects

Protected-resource metadata, the WWW-Authenticate challenge, PKCE, audience validation and no token passthrough: the MCP authorization spec, step by step, with code and tests.

· mcpprobe contributors

The MCP authorization spec is short, but it is precise, and most of the precision is on the server side. Authorization is optional in the protocol, yet the spec says HTTP transports should conform to it, and the cost of skipping it is concrete: in one 2026 audit of ten remote servers, four were genuinely open with no authentication at all.

This post walks through what the 2025-11-25 revision expects of a server, which is still what most clients and SDKs speak, then notes what changed in 2026-07-28. The code is Python with Starlette and PyJWT, but every step maps to any stack.

Your server is a resource server

In OAuth terms the MCP server is a resource server. It does not issue tokens; an authorization server does. Your job is to tell clients where that authorization server is, accept only tokens that were issued for you, and refuse everything else in a way clients can act on. Stdio servers are different: the spec says they should not use this flow and should take credentials from the environment.

Step 1: publish protected-resource metadata

Servers must implement RFC 9728 Protected Resource Metadata, and the document must list at least one authorization_servers entry. For a server at https://mcp.example.com/mcp, publish it at the path-suffixed well-known location; clients try that first and then the root.

/.well-known/oauth-protected-resource/mcp
{
  "resource": "https://mcp.example.com/mcp",
  "authorization_servers": ["https://auth.example.com"],
  "scopes_supported": ["tools:read", "tools:call"],
  "bearer_methods_supported": ["header"]
}

Step 2: challenge correctly

A request without a valid token gets 401 and a WWW-Authenticate header that points at the metadata. Adding a scope hint tells the client what to ask for. This header is how a client that has never seen your server discovers the whole flow, so it is worth getting exactly right.

401 responseOutput
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp", scope="tools:read"
Content-Type: application/json

{"error":"unauthorized"}

Step 3: make sure the authorization server supports PKCE

Clients find the authorization server's own metadata through RFC 8414 (/.well-known/oauth-authorization-server) or OpenID Connect discovery. They must use PKCE with S256, and they must refuse to continue if code_challenge_methods_supported is missing. If you run your own authorization server, check that field; if a hosted provider runs it, check it anyway. All endpoints must be HTTPS.

Step 4: validate every token, and only accept your own

Clients send the RFC 8707 resource parameter (your canonical URI) when they ask for a token, so a correctly issued token names you as its audience. The server must check that audience on every request and must not accept a token issued for anything else. Two more rules are easy to miss: tokens go in the Authorization header and must never be accepted from a query string, and invalid or expired tokens get 401.

auth.py
import jwt  # PyJWT
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse

RESOURCE = "https://mcp.example.com/mcp"          # canonical URI, no trailing slash
ISSUER = "https://auth.example.com"
PRM_URL = "https://mcp.example.com/.well-known/oauth-protected-resource/mcp"
jwks = jwt.PyJWKClient(f"{ISSUER}/.well-known/jwks.json")


def challenge(status: int, error: str | None = None, scope: str | None = None) -> JSONResponse:
    params = [f'resource_metadata="{PRM_URL}"']
    if error:
        params.append(f'error="{error}"')
    if scope:
        params.append(f'scope="{scope}"')
    return JSONResponse(
        {"error": error or "unauthorized"},
        status_code=status,
        headers={"WWW-Authenticate": "Bearer " + ", ".join(params)},
    )


class BearerAuth(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        if request.url.path.startswith("/.well-known/"):
            return await call_next(request)            # metadata stays public
        if "access_token" in request.query_params:
            return challenge(400, "invalid_request")   # never accept tokens in the URL
        header = request.headers.get("authorization", "")
        if not header.lower().startswith("bearer "):
            return challenge(401, scope="tools:read")
        token = header[7:]
        try:
            key = jwks.get_signing_key_from_jwt(token).key
            claims = jwt.decode(
                token, key, algorithms=["RS256"],
                audience=RESOURCE,
                issuer=ISSUER,
            )
        except jwt.PyJWTError:
            return challenge(401, "invalid_token")
        request.state.scopes = set(claims.get("scope", "").split())
        return await call_next(request)

The highlighted line is the one most implementations forget. Without it, any token your authorization server ever issued, for any API, works on your MCP server. In production, cache the key set rather than fetching it per request.

Step 5: scopes, per tool

A valid token with too little scope should not quietly succeed. The spec says to answer 403 with error="insufficient_scope" and a scope hint, which lets the client ask the user for more access instead of failing silently.

scopes.py
def require_scope(request, needed: str):
    if needed not in request.state.scopes:
        return challenge(403, "insufficient_scope", scope=needed)
    return None

Step 6: never pass tokens through

If your tools call an upstream API, do not forward the client's token to it. That token was issued for you, and forwarding it is exactly the passthrough the spec forbids: the upstream cannot tell who it was meant for, and your server becomes a confused deputy. Get your own credential for the upstream. If you proxy with a static client ID, the spec requires per-client consent for the same reason.

Client registration, briefly

Clients register with your authorization server in this order of preference: pre-registration, then Client ID Metadata Documents (the client ID is an HTTPS URL to a JSON document describing the client), then Dynamic Client Registration (RFC 7591), then asking the user. If your authorization server fetches metadata documents, treat those URLs as untrusted input; the spec calls out the SSRF risk.

What 2026-07-28 changes

The current revision keeps the same shape and tightens it. Clients validate the iss parameter (RFC 9207), credentials are bound to the authorization server that issued them, Dynamic Client Registration is deprecated in favour of Client ID Metadata Documents, and registration requires an application_type. A server that follows the steps above needs no change; an authorization server that relies only on dynamic registration does.

Test it

Three requests tell you most of what you need. The scanner then covers the cases that are tedious by hand: a self-minted token with the wrong audience, a token in the query string, a forged Origin.

Terminal
# 1. no token: expect 401 with resource_metadata in WWW-Authenticate
curl -si -X POST https://mcp.example.com/mcp \
  -H 'content-type: application/json' \
  -H 'accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | head -n 3

# 2. the metadata document resolves
curl -s https://mcp.example.com/.well-known/oauth-protected-resource/mcp

# 3. let the scanner try the cases you did not think of
mcpprobe https://mcp.example.com/mcp

These are checks AUTH-01 to AUTH-04 in the checks catalogue, and the MCP security checklist puts them in context with the rest of the server.

Sources

  1. MCP specification 2025-11-25: Authorization (2025-11-25)
  2. MCP specification: Versioning (checked 27 Oct 2026)
  3. MCP 2026-07-28 changelog (mirror) (2026-07-28)
  4. RFC 9728: OAuth 2.0 Protected Resource Metadata (Apr 2025)
  5. RFC 8707: Resource Indicators for OAuth 2.0 (Feb 2020)
  6. AppSec Santa: MCP server security audit 2026 (remote servers without auth) (Apr–Jun 2026)