Why architecture matters here

Why does MCP need its own authorization story at all — isn't OAuth solved? Because agents break OAuth's central casting. Classic OAuth assumes a known application, registered ahead of time with each API it uses, driving a user through consent for a fixed scope set. Agent hosts invert every assumption: a user can point their agent at any MCP server discovered at runtime (no pre-registration), the 'application' is a general-purpose host acting across many servers (client identity must be established dynamically), and the actions are initiated by a model whose requests the user doesn't individually review (consent and scoping must happen up front, and be enforceable server-side). MCP's adoption of dynamic client registration, discovery metadata, and mandatory PKCE is precisely the subset of the OAuth ecosystem that makes runtime-discovered, user-delegated access workable rather than terrifying.

The security stakes are agent-specific. An MCP server aggregates capabilities — read email, query the CRM, file tickets — and the agent wielding it is susceptible to prompt injection: hostile content in a fetched document instructing it to exfiltrate. Authorization architecture is the blast-radius control: if the token presented to the email server is scoped to read-only and audience-bound to that server alone, injection can read but not send, and can't replay the token against the CRM server. Every scoping and binding decision is a wall the worst day crashes into — which is why 'just use a long-lived admin API key in an env var', the local-server habit, is the exact pattern the remote spec exists to end.

The confused-deputy hazard deserves its own paragraph because MCP servers are structurally deputies: trusted by downstream APIs, commanded by untrusted-ish agents. Token passthrough — forwarding the client's token downstream — collapses the chain's audit and revocation boundaries and lets a token minted for one context act in another; the spec's prohibition, and its push toward token exchange for downstream hops, is the difference between a chain of accountable identities and a relay of ambient authority. Teams building MCP servers get this right at design time or explain it to an incident reviewer later.

The architecture: every piece explained

Top row: the cast. The MCP client lives inside a host (IDE, chat app, agent runtime) and speaks MCP to servers on the user's behalf. A remote MCP server is an HTTP service (streamable HTTP transport) exposing tools/resources/prompts — and, in OAuth terms, a resource server: every request must carry a valid access token, validated for signature, expiry, and — critically — audience (this token was minted for this server). The authorization server issues those tokens; it may be embedded in the MCP server's domain or an external IdP (corporate SSO, a hosted identity provider) — MCP servers declare which via metadata. Protected resources are whatever lies behind: SaaS APIs, databases, internal services.

Middle row: the choreography. Discovery: a client hitting a server without a token receives 401 plus WWW-Authenticate pointing at protected-resource metadata (RFC 9728), which names the authorization server(s); the client then fetches the AS's own metadata (endpoints, capabilities). Dynamic client registration (RFC 7591) lets the client obtain a client ID on the spot — no human pre-registration with each of thousands of servers. The PKCE authorization-code flow is the consent moment: the host opens the user's browser to the AS; the user authenticates (their SSO, their passkeys) and sees a consent screen naming the client and requested scopes; the code returns via redirect and is exchanged — with the PKCE verifier, mandatory in OAuth 2.1 — for tokens. Access tokens come back audience-bound and scoped; refresh tokens (where granted) let the host maintain access without re-consent, subject to rotation and revocation.

Bottom rows: the two trust worlds and the far side. Local stdio servers skip all of this by design: launched by the host as a subprocess, they inherit OS-level trust, and their credentials (API keys in env/config) are the user's own — appropriate for personal tooling, and the reason the spec scopes OAuth to HTTP transports. Downstream auth: when the MCP server calls the APIs behind it, it must not passthrough the inbound token; patterns are token exchange (RFC 8693 — trade the user-context token for a downstream-audience token), the server's own service identity where user context isn't needed, or per-user linked credentials stored server-side. The ops strip carries the daily grind: minimal scope design, consent screens that humans can actually evaluate, token lifetimes and rotation policy, and audit logs that answer 'which user's agent did this, under which consent'.

MCP authorization — OAuth 2.1 between clients and remote serversagents act on behalf of users, provablyMCP client (host)agent app / IDEMCP serverOAuth resource serverAuthorization serverissues tokensProtected resourcesAPIs, data, toolsDiscoveryprotected resource metadataDynamic registrationclients without preconfigPKCE flowuser consent + code exchangeAccess tokensaudience-bound, scopedLocal serversstdio: OS trust, env credsDownstream authtoken exchange, no passthroughOps — scope minimization + consent UX + token lifecycle + auditdiscoverregisterauthorizevalidatetrust modelconsentexchangeoperateoperate
MCP authorization: clients discover the server's authorization server, register dynamically, run PKCE consent, and present audience-bound tokens per request.

End-to-end flow

Walk the first-contact flow as a user experiences it. In their agent app, a user adds mcp.acme-crm.com. The client connects; the server answers 401 with resource metadata; the metadata names auth.acme.com as the authorization server. The client fetches AS metadata, registers dynamically (getting a client ID), and constructs the authorization URL with PKCE challenge and the scopes the server's metadata suggests for its capabilities: crm.contacts.read crm.deals.read. The host pops the system browser: the user lands on Acme's real login (their corporate SSO — the agent never sees the password), then a consent screen: 'AgentApp wants to: read contacts, read deals. Not write. Not admin.' Approve → redirect → code exchange with verifier → access token (audience: mcp.acme-crm.com, 1-hour expiry) plus refresh token. The client reconnects with the bearer token; the session initializes; tools appear in the agent.

Now a request: the agent calls the search_contacts tool. The server validates the token (signature via the AS's JWKS, expiry, audience, scope covers the tool), then needs the actual CRM API — and here the passthrough prohibition bites: instead of forwarding the MCP token, it performs a token exchange with the AS, presenting the inbound token and requesting one scoped for api.acme-crm.internal with the same user context. The CRM API sees a properly audienced token carrying the user's identity; its audit log and the MCP server's audit log both attribute the read to this user via AgentApp under this consent. When the access token expires mid-session, the client refreshes silently; when the user later revokes AgentApp from Acme's account page, the refresh token dies and the next request 401s — the agent's access ends where the user's consent ends, provably.

The attack-day version validates the design. A prompt-injected agent tries to exfiltrate by calling a write tool: the token's scopes don't cover it — 403, logged. It tries replaying the token against a different MCP server it also talks to: audience validation rejects it. It convinces the host to request broader scopes: the user sees a new consent screen asking for write access out of nowhere — the human checkpoint the architecture refuses to remove. Damage: contained to exactly what was consented, with an audit trail at every hop.

MCP authorization on the wire: the 401, the two metadata documents, the PKCE exchange step by step, and a server-side token check

The flow above, reduced to the bytes each party sends. Every document and parameter has a specification behind it, and the names matter because the client libraries look for them exactly.

It starts with the refusal. A request without a token gets a 401 whose WWW-Authenticate header points at the server's protected-resource metadata (RFC 9728). That document names the authorization server, and the authorization server's own metadata (RFC 8414) names its endpoints:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.acme-crm.com/.well-known/oauth-protected-resource"

GET https://mcp.acme-crm.com/.well-known/oauth-protected-resource
{
  "resource": "https://mcp.acme-crm.com",
  "authorization_servers": ["https://auth.acme.com"],
  "scopes_supported": ["crm.contacts.read", "crm.deals.read", "crm.deals.write"],
  "bearer_methods_supported": ["header"]
}

GET https://auth.acme.com/.well-known/oauth-authorization-server
{
  "issuer": "https://auth.acme.com",
  "authorization_endpoint": "https://auth.acme.com/authorize",
  "token_endpoint": "https://auth.acme.com/token",
  "registration_endpoint": "https://auth.acme.com/register",
  "jwks_uri": "https://auth.acme.com/.well-known/jwks.json",
  "code_challenge_methods_supported": ["S256"],
  "grant_types_supported": ["authorization_code", "refresh_token"]
}

Now PKCE, which is where the oauth pkce searches land. The client makes a random secret, the verifier, and sends only its hash, the challenge, with the authorization request. When it later redeems the code it sends the verifier itself; the authorization server hashes it and compares. A code stolen from the redirect is useless without the verifier, which never left the client. That is the whole mechanism, and it is why a desktop or IDE host, which cannot keep a client secret, can still run the authorization-code grant safely, and why OAuth 2.1 makes PKCE mandatory while removing the implicit and password grants:

import base64, hashlib, secrets

verifier  = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()   # 43 characters
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
# 1. the host opens the system browser at (values from RFC 7636's own example):
GET https://auth.acme.com/authorize?response_type=code
   &client_id=cl_8f2a91                      (issued moments ago by dynamic registration, RFC 7591)
   &redirect_uri=http://127.0.0.1:53121/callback
   &scope=crm.contacts.read%20crm.deals.read
   &state=n0nce-9c1
   &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
   &code_challenge_method=S256
   &resource=https%3A%2F%2Fmcp.acme-crm.com

# 2. after login and consent the browser is sent to the loopback redirect with ?code=...&state=n0nce-9c1
# 3. the host redeems the code, proving it started the flow:
POST https://auth.acme.com/token
grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=http://127.0.0.1:53121/callback&client_id=cl_8f2a91
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
&resource=https%3A%2F%2Fmcp.acme-crm.com

{ "access_token": "eyJ...", "token_type": "Bearer", "expires_in": 3600,
  "refresh_token": "8xLOxBtZp8", "scope": "crm.contacts.read crm.deals.read" }

Two parameters are specific to this setting. The loopback redirect_uri with an ephemeral port follows RFC 8252 for native applications. The resource parameter is RFC 8707, and it is how the token comes back audience-bound to this MCP server and no other; the June 2025 revision of the MCP specification requires clients to send it, which is what makes the replay defence in the attack-day paragraph above enforceable.

On the server, every request runs the check the architecture section describes: signature, issuer, expiry, audience, then scope. With JWT access tokens and the authorization server's JWKS it is short:

import jwt                                    # PyJWT
from jwt import PyJWKClient

JWKS = PyJWKClient("https://auth.acme.com/.well-known/jwks.json")
RESOURCE = "https://mcp.acme-crm.com"
ISSUER = "https://auth.acme.com"

def authorize(request, required_scope):
    auth = request.headers.get("Authorization", "")
    if not auth.startswith("Bearer "):
        return 401, {"WWW-Authenticate": f'Bearer resource_metadata="{RESOURCE}/.well-known/oauth-protected-resource"'}
    token = auth[7:]
    try:
        key = JWKS.get_signing_key_from_jwt(token).key
        claims = jwt.decode(token, key, algorithms=["RS256", "ES256"], audience=RESOURCE, issuer=ISSUER)
    except jwt.PyJWTError:
        return 401, {"WWW-Authenticate": 'Bearer error="invalid_token"'}
    if required_scope not in claims.get("scope", "").split():
        return 403, {"WWW-Authenticate": f'Bearer error="insufficient_scope", scope="{required_scope}"'}
    return 200, claims        # claims["sub"] is the user: log it next to the tool name and arguments

If the authorization server issues opaque tokens instead of JWTs, the same function calls its introspection endpoint (RFC 7662) in place of jwt.decode. Whatever the token format, the variable named token must never appear in a request the server makes to the systems behind it: the downstream call uses an exchanged token (RFC 8693) or the server's own identity, as the passthrough prohibition above requires.