2026-07-15
MCP OAuth Authorization: A 30-Case Resource-Binding Drill
A local resource-binding drill tests protected-resource discovery, exact audiences, issuer checks, operation scopes, PKCE, and token-passthrough failures before a remote MCP server goes live.
The OAuth incident worth rehearsing is not “the login page failed.” It is “a perfectly valid token arrived at the wrong MCP server and still worked.”
I built a local admission drill around that failure. The fixture never opened a browser or contacted an authorization server. It varied one field at a time across protected-resource metadata, authorization-server metadata, the authorization response, the token request, token claims, and the eventual tool call. All 30 expected decisions matched: two ALLOW, two CHALLENGE, two STEP_UP, and 24 BLOCKED.
The important result was not that a token could be validated. It was that five identities stayed joined: the exact MCP resource, the authorization issuer, the client, the token audience, and the current operation. When any one drifted, execution stopped before a tool received authority.
The fixture SHA-256 is 71f053bc9e7cdef16ea8ddddf7da219b0addba462544846f62f4bdbdc95c2b8c. The baseline protected resource is https://mcp.example.test/tenant/acme/mcp. Those values make the experiment inspectable instead of leaving “secure OAuth” as a configuration mood.
The token was valid. The resource was wrong.
A remote MCP server is an OAuth resource server. That sounds like ordinary API security, but MCP clients discover authorization details dynamically and may connect to many independent servers. A token can be well formed, correctly signed, unexpired, and still be useless for the server receiving it.
RFC 8707 gives the client a resource parameter so the authorization server knows where the token will be redeemed. That is not another spelling for scope. Scope describes what the client may do; the resource identifies where it may do it. The authorization server can then restrict the token audience to that resource.
The fixture rejects a token whose audience names https://other.example.test/mcp. It also rejects a resource that changes between the authorization request and token request. Both failures are easy to hide behind a successful signature check.
A valid bearer token is not a passport. It is a ticket for one destination, one client, and a bounded set of actions.
Discovery begins at the protected server
The stable MCP authorization specification requires RFC 9728 protected-resource metadata. An unauthenticated request can receive a 401 challenge whose WWW-Authenticate value points the client to that metadata. The document identifies the resource and the authorization servers permitted to issue credentials for it.
This reverses a dangerous assumption. The client should not guess an issuer from a hostname, accept the first login endpoint it sees, or treat a redirect chain as trustworthy discovery. It starts from the server it intended to call, retrieves the protected-resource description under a bounded policy, and checks that the metadata describes that same resource.
The local drill keeps the metadata URL on the resource origin, permits at most two redirects, caps the document at 64 KiB, requires JSON, and refuses private-address resolution. Those limits are fixture policy rather than additions to RFC 9728. They make its SSRF and metadata-validation warnings executable.
The exact resource is part of the contract
The baseline does not use https://mcp.example.test. It uses https://mcp.example.test/tenant/acme/mcp. RFC 8707 recommends the most specific resource URI the client can identify. That matters when one host serves several tenants or applications with different authorization boundaries.
A canonical resource identifier should be absolute and should not contain a fragment. The fixture is stricter for this deployment: HTTPS is required, the resource must exactly equal the configured tenant endpoint, and the same value must appear during authorization and token exchange. An HTTP variant, a fragment-bearing variant, a missing value, and a tenant-path change all fail.
Exact comparison can expose legacy integrations that relied on broad host audiences or authorization-server defaults. That friction is useful. If two resource names are genuinely equivalent, define the mapping in deployment configuration and test it. Do not teach the MCP server to treat every nearby audience as close enough.
Setup: three issuer checkpoints, not one
The drill checks issuer identity in authorization-server metadata, in the authorization response, and in the access token. They are not redundant.
- Metadata issuer: RFC 8414 makes the HTTPS issuer identifier the anchor for authorization-server endpoints and capabilities.
- Authorization-response issuer: RFC 9207 adds
issto the response so a client can reject a mix-up before sending a code to the wrong token endpoint. - Token issuer: the resource server still needs to verify that the accepted key and claim set belong to the configured authorization server.
Changing any one issuer to https://evil.example.test produced a named blocker. The authorization-response comparison is a simple string comparison, as RFC 9207 requires. Normalizing away a path, slash, or hostname difference would erase the identity the check is meant to preserve.
Thirty mutations produced four distinct exits
The distribution was intentionally lopsided. Most malformed states were not recoverable authentication prompts; they were identity or transport violations that deserved a hard stop. Only absence of a credential produced CHALLENGE, and only a valid credential missing one operation scope produced STEP_UP.
| Case family | Decision | First reason |
|---|---|---|
| Exact resource, issuer, client, audience, and tool scope | ALLOW | The entire authorization chain is bound. |
| No bearer token | CHALLENGE | The response names protected-resource metadata and the current operation scope. |
| Valid token missing the current tool scope | STEP_UP | The client can request one narrower capability without discarding valid base access. |
| Wrong audience or resource drift | BLOCKED | The credential belongs to another destination. |
| Metadata, response, or token issuer mismatch | BLOCKED | The authorization server identity changed. |
| Expired, premature, unsigned, or wrong-algorithm token | BLOCKED | Cryptographic or temporal validity failed. |
| Redirect, PKCE, or client identity drift | BLOCKED | The client side of the grant is no longer the registered flow. |
| Unsafe metadata location or shape | BLOCKED | Discovery crossed the local fetch boundary. |
| Inbound bearer forwarded upstream | BLOCKED | A token issued for this MCP resource cannot be reused as an ambient downstream credential. |
All 30 expected outcomes matched. That is evidence about the admission function and fixture, not a certification of a live identity provider. The probe did not fetch metadata, verify a real signature, open a redirect, exchange a code, call an MCP server, or execute a tool.
A challenge is not an error page
Missing authentication and insufficient authorization are different protocol states. The fixture returns CHALLENGE when no token is present and only when the challenge points to the expected protected-resource metadata and names the scope for the attempted operation.
This keeps the client anchored. A generic “go authenticate” response invites it to rediscover configuration through ambient or attacker-controlled context. A resource-bound challenge tells the client which server is asking, where its metadata lives, and what minimum access the current operation needs.
The challenge must still be validated. If the metadata URL moves to another origin, resolves to a private address, exceeds the size budget, redirects too far, or returns HTML, the drill blocks it. A WWW-Authenticate header is input, not authority.
One operation stopped at a narrower gate
A client with mcp:connect and tools:read can list tools in the baseline. It cannot call the preview-deployment tool, whose contract requires tools:deploy-preview. The packet enters STEP_UP and names precisely that missing scope.
This is more honest than issuing an all-tools token at login. Tool catalogs change, and the consequences of a read operation differ from a deployment. A server should authorize the operation about to run, not the session in the abstract.
Step-up also produces better operational evidence. The log can state that authentication succeeded, the resource and issuer matched, and one tool scope was absent. Operators do not have to infer whether the incident was an expired session, a broken identity provider, or a deliberate least-privilege stop.
The resource server must reject token passthrough
The final mutation sets forwardsBearerToken to true. It is blocked even though the token is otherwise valid.
The access token was minted for the MCP resource. Forwarding it to an upstream database, issue tracker, or model API changes its audience and exposes a bearer credential to a different service. The MCP authorization specification explicitly treats token passthrough as an anti-pattern. Downstream calls need their own credential and audience, acquired through a server-side trust relationship appropriate to that service.
This boundary should be architectural, not a code-review convention. Strip the inbound authorization header before constructing upstream requests. Give connectors separate credential objects. Test that the original bearer value never appears in outbound headers or logs.
The refused neighbors are the release proof
A single successful authorization-code flow proves very little. The release gate needs nearby failures that preserve almost everything about the happy path.
- Keep the signature valid and change only the token audience.
- Keep the audience correct and change only the tenant resource in the token request.
- Keep discovery valid and return a different
issvalue in the authorization response. - Keep base scopes valid and remove only the current tool scope.
- Keep the token valid and attempt to forward it to one upstream request.
- Keep the metadata body valid and move its URL to an untrusted origin or private address.
The first four should not collapse into one “401” assertion. Missing credentials should produce a bounded challenge. Missing tool authority should produce a bounded step-up. Identity drift, malformed discovery, and token misuse should remain blocked.
The fixture report preserves the expected decision, observed decision, and first reason for every mutation. That gives another engineer enough information to reproduce the boundary without access to the drafting session.
Troubleshooting by broken binding
- Every token gets
invalid_token: compare the configured resource identifier with the exact audience emitted by the authorization server before changing signature code. - The client loops through discovery: inspect the protected-resource metadata URL, cache lifetime, resource value, and advertised authorization server as a single record.
- A second identity provider breaks a previously working client: validate RFC 9207
issand keep issuer-specific state; this is the mix-up boundary becoming visible. - A read token can call every tool: authorization is happening at connection time rather than immediately before tool execution.
- Scope errors trigger a full login: return an operation-specific step-up challenge instead of discarding valid base access.
- Metadata fetches reach internal hosts: enforce DNS/IP policy on every hop and revalidate after redirects.
- Upstream APIs accept the MCP token: remove passthrough and provision audience-specific server credentials.
What the evidence does not claim
The 30-case drill is intentionally local. It proves that a small admission function distinguishes bounded authentication, step-up authorization, and unsafe state using a fixed fixture. It does not prove interoperability with a specific identity provider, correctness of a JWT library, resilience of a production metadata fetcher, security of dynamic client registration, or availability under real traffic.
Those require an authorized integration environment with real redirect URIs, keys, rotation, clocks, caches, failure injection, and revocation behavior. The local fixture should remain in that test suite because it catches identity drift faster and more deterministically than an end-to-end login.
The tradeoff is predictable: exact resource and issuer binding rejects convenient defaults. That is preferable to a remote MCP server whose trust boundary changes according to whichever token or metadata document happens to arrive.
A GolemWorkers agent can carry runtime configuration and approval state, but the OAuth boundary still belongs at the resource and tool admission layer. Orchestration cannot repair an audience check that never happened.
Primary sources and evidence boundary
- MCP Authorization, stable specification dated 2025-06-18
- RFC 9728: OAuth 2.0 Protected Resource Metadata
- RFC 8707: Resource Indicators for OAuth 2.0
- RFC 8414: OAuth 2.0 Authorization Server Metadata
- RFC 9207: OAuth 2.0 Authorization Server Issuer Identification
The evidence here is the hash-bound local fixture and findings report. No live OAuth credential, authorization server, browser redirect, token endpoint, signing key, remote MCP server, tool call, or downstream API was exercised.