You connect Claude Code to an MCP server. The server requires authentication. Your client sends a request; the server answers with a 401 Unauthorized and a WWW-Authenticate: Bearer header that carries a resource_metadata URL. What happens next is an OAuth 2.1 flow defined by three RFCs and a set of MCP-specific extensions that, taken together, solve one of the harder problems in agent security: making sure a token obtained to talk to this MCP server cannot be replayed against that one.
This post traces the full flow — what the spec says, what the RFCs actually provide, and where the security pitfalls hide in practice.
The flow: from 401 to a resource-bound token
The MCP authorization model (revision 2025-11-25, carried unchanged into the release candidate — frozen 2026-05-21 — for the final spec scheduled for 2026-07-28) is a two-phase process. Phase 1 is detection: the 401 carrying WWW-Authenticate: Bearer resource_metadata="..." tells the client this server is OAuth-protected and where to find its metadata. Phase 2 is authorized access: consume the metadata, discover the authorization server, obtain a token bound to this specific server, and use it.
The concrete steps:
-
401 + WWW-Authenticate: The MCP server rejects an unauthenticated request. The
resource_metadataparameter in the challenge points to the server’s Protected Resource Metadata document. -
PRM fetch (RFC 9728): The client GETs the
/.well-known/oauth-protected-resourceURL. The response is a JSON document declaring the server’s canonical resource URI, the authorization server(s) that protect it, and the scopes the resource supports. The client validates that the document’sresourcefield matches the server it intended to reach — a mismatch is an impersonation signal and must be rejected. -
AS discovery (RFC 8414): Using the issuer URL from the PRM’s
authorization_serversarray, the client walks the well-known candidates (/.well-known/oauth-authorization-server, then/.well-known/openid-configuration) to fetch the authorization server metadata. The issuer in the returned document must be byte-identical to the issuer it was fetched for — not “equivalent after normalization”, not “close enough”. Byte-identical. -
Token acquisition (RFC 8707): The client requests a token from the AS’s token endpoint, including
resource=<canonical server URI>in both the authorization and token requests. This binds the token’s audience to this specific MCP server. The AS issues a token whose audience is that resource, and the client presents it to the server. -
Authorized access: The client uses the token to call the MCP server’s read-only introspection methods (
tools/list,resources/list, etc.). The token proves the client is authorized; the resource indicator proves the token was minted for this server.
What RFC 9728 actually provides
RFC 9728 (Protected Resource Metadata) is a discovery mechanism. It answers: “which authorization server protects this resource, and what does it expect?” It does not authenticate the server, validate the token, or enforce access control. Those are separate concerns.
The PRM document has one required field (resource) and a set of optional ones, of which authorization_servers matters most. The MCP spec tightens the RFC’s optionality: at least one authorization server is required. A PRM document that lists none is treated as an error.
The resource field is the canonical URI of the protected resource. The client must compare it against the server it intended to contact, and reject a mismatch:
// RFC 9728 §3.3: the PRM's resource value MUST identify the protected
// resource the client is addressing — simple string comparison against
// the resource indicator this client binds its tokens to.
if prm.Resource != c.resource {
return authServerMetadata{}, fmt.Errorf(
"mcp: oauth: protected resource metadata declares resource %q, "+
"expected %q (RFC 9728 §3.3 reject)", prm.Resource, c.resource)
}
This check prevents a class of attack where a malicious or misconfigured PRM document claims to speak for a different resource. The comparison is not normalized — it is a direct string match against the canonical resource URI the client computed for the server URL it was given.
RFC 8707 resource indicators — why token binding matters
Without resource indicators, an access token obtained from an AS can potentially be used at any resource server that AS protects. If you have two MCP servers — say, a read-only documentation server and a code-execution sandbox — both behind the same identity provider, a token obtained for one could be presented to the other. That is a confused-deputy problem.
RFC 8707 solves this by adding a resource parameter to the authorization and token requests. The AS issues a token whose audience is explicitly that resource URI. A properly validating resource server rejects a token whose audience does not match its own identity.
In practice, the token request includes the resource indicator alongside the grant:
form := url.Values{
"grant_type": {"client_credentials"},
"resource": {c.resource}, // RFC 8707 — audience-bind the token
}
This appears in every grant type the connector uses: client credentials, authorization code redemption, and refresh token rotation. The resource indicator is not optional — it is present in every token request, so every token is audience-bound by construction.
The byte-identical issuer check
The most security-critical validation in the entire flow is also the simplest to state and the easiest to get wrong: the issuer value in the AS metadata document must be byte-identical to the issuer the client used to construct the well-known URL.
Not case-insensitively equal. Not equivalent after scheme normalization. Not the same after removing a trailing slash. Byte-identical. RFC 8414 section 3.3 is explicit about this, and the MCP spec inherits the requirement.
// discoverASMetadata: REFUSES a document whose issuer is not
// BYTE-IDENTICAL to the issuer it was fetched for (RFC 8414 §3.3).
// A mismatched document is an impersonation signal.
if as.Issuer != issuer {
return authServerMetadata{}, fmt.Errorf(
"mcp: oauth: AS metadata at %s declares issuer %q, "+
"expected %q (RFC 8414 §3.3 reject)", cand, as.Issuer, issuer)
}
Why so strict? Because an attacker who controls DNS or sits on the network path can serve a metadata document that points to their own token endpoint while claiming to be a legitimate issuer. If the client normalized the issuer before comparing, https://auth.example.com and https://AUTH.example.com would match — and the attacker’s document would be accepted. Byte-identical comparison closes this.
The same discipline carries into RFC 9207 (authorization response issuer validation). When the client starts an authorization-code flow, it records the issuer from the validated AS metadata. On the redirect back, the iss parameter in the response must match that recorded value — again, byte-for-byte, no normalization — before the authorization code is redeemed. This is the mix-up defense: without it, a malicious AS could intercept the code and have the client redeem it at the attacker’s token endpoint.
Client identification: CIMD replaces DCR
The MCP spec defines a priority order for how a client identifies itself to the authorization server:
- Pre-registered credentials — the operator provisions a
client_idandclient_secretahead of time - CIMD (Client ID Metadata Documents) — the client hosts a JSON document at an HTTPS URL; that URL is the
client_id - Dynamic Client Registration (RFC 7591) — the client registers itself at the AS’s registration endpoint
- Prompt the user — not applicable for headless agents
DCR is deprecated in the release candidate for the final spec scheduled for 2026-07-28, in favor of CIMD. The reason is operational: DCR creates persistent client state at the authorization server. Every agent that registers itself leaves behind a client_id/client_secret pair that the AS must store, and nobody tracks or revokes them. For a fleet of agents, this is uncontrolled credential sprawl.
CIMD inverts the model. The client hosts a document at a URL it controls. The AS fetches the document when it needs to validate the client, caches it according to HTTP cache headers, and stores nothing permanently. Key rotation is a document update. Decommissioning a client is removing the document. No orphaned registrations accumulate at the AS.
The tradeoff: CIMD requires the client to run an HTTPS endpoint. For a self-hosted governance plane, that is natural — the plane already runs HTTPS services. For a CLI tool on a developer laptop, it is less so, which is why pre-registered credentials remain the first option in the priority order.
A CIMD identity cannot carry a shared secret (the document URL is public — a secret in it would be a credential leak). Client authentication uses private_key_jwt (RFC 7523): the client signs a short-lived JWT with a private key whose public counterpart is published in the CIMD document’s jwks field.
The “never passthrough” rule
A structural defense that is easy to overlook: the connector only ever uses a token it obtained itself, for a specific server, through the discovery flow described above. It never accepts a token from a third party and forwards it to an MCP server. It never reads a token from an inbound request and passes it through.
This is the confused-deputy defense at the protocol level. If a client forwarded tokens it received, an attacker could present a token scoped to a low-privilege resource and have the client forward it to a high-privilege one (or vice versa — extract a high-privilege token by tricking the client into presenting it to an attacker-controlled server). By obtaining its own tokens and never touching anyone else’s, the connector cannot be made to act as a token relay.
Token passthrough is not just discouraged — it is structurally impossible. The connector’s HTTP client for OAuth flows is separate from any inbound request handler. There is no code path that reads a bearer token from an incoming request and writes it to an outgoing one.
SSRF: the DNS-rebinding defense
Every metadata and token endpoint URL the connector fetches is SSRF-guarded at two layers. The first is a pre-flight check: the URL must be HTTPS (except loopback for local development) and must not be a literal reserved IP address.
The second is a dial-time check that closes the DNS-rebinding TOCTOU. A hostname that resolves to a public IP during the pre-flight check could rebind to a private IP by the time the socket is dialed. The connector’s HTTP client installs a net.Dialer.Control function that inspects the concrete resolved IP at connect time and refuses any reserved address. This is the authoritative check — the pre-flight is a fast reject for obvious cases, but the dial-time check is the one that matters.
Scope step-up
An MCP server can answer a request with WWW-Authenticate: Bearer error="insufficient_scope" scope="mcp:tools:list mcp:resources:read". The spec (SEP-835/SEP-2350) defines how the client handles this: compute the union of the scopes it previously requested and the scopes the server just challenged, then re-acquire a token with that expanded set. The union preserves previously granted permissions and adds the new ones. The step-up happens once — a second insufficient-scope challenge on the same request is not retried, to prevent infinite loops.
The server is allowed to be stateless in its challenge: it names only the scopes the current operation needs, not the full set the client might have requested before. Client-side accumulation makes this work without the server tracking per-client scope history.
What this means in practice
The OAuth flow for MCP servers is well-specified and, when implemented strictly, addresses the real attack surfaces: token replay across servers, metadata impersonation, DNS-rebinding SSRF, and uncontrolled client-credential sprawl. The byte-identical issuer check, the resource indicator in every token request, and the structural no-passthrough rule are the core defenses. They are not features you can half-implement — each one is a hard check that fails closed.
The harder problem is operational. Teams deploying MCP servers need to:
- Publish a valid PRM document at
/.well-known/oauth-protected-resourcewith aresourcefield that matches their server’s canonical URI - Use an AS that supports resource indicators — many identity providers still do not, or treat the
resourceparameter as advisory rather than audience-binding - Move from DCR to CIMD before the deprecation becomes a removal, or pre-register credentials explicitly
- Pin credentials to an issuer to prevent silent cross-issuer reuse when the AS topology changes
The MCP connector documentation covers the operational configuration, and the security model describes how the resource-bound token fits into the broader access map.