Guide · MCP specification 2026-07-28 · Read on 29 October 2026
MCP authorization, step by step, then tested.
Short answer. An HTTP MCP server is an OAuth 2.1 resource server. It answers an unauthenticated request with a 401 that points to its Protected Resource Metadata (RFC 9728), the client finds the authorization server from there, runs authorization code with PKCE S256 and a resource parameter naming the server (RFC 8707), and sends the token as a Bearer header. The server must then accept only tokens issued for itself, and must never forward them upstream. Most broken implementations fail one of two checks: the audience, or the scope per tool.
How does MCP authorization work?
Three roles take part. The MCP client is an OAuth 2.1 client acting for a user. The MCP server is the resource server that checks tokens. The authorization server talks to the user and issues tokens; it can be hosted with the MCP server or be the identity provider you already run. The specification builds on OAuth 2.1 (draft 13) and a handful of RFCs, and keeps a subset of each.
How does a client find the authorization server?
The MCP server must implement OAuth 2.0 Protected Resource Metadata (RFC 9728), and the document must list at least one entry in authorization_servers. The server advertises it in one of two ways: a resource_metadata parameter in the WWW-Authenticate header of its 401, or a well-known URI, either at the path of the MCP endpoint (/.well-known/oauth-protected-resource/public/mcp for /public/mcp) or at the root. Clients must support both and prefer the header.
401 from the MCP serverthe challengeHTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
scope="files:read"/.well-known/oauth-protected-resourceminimal metadata{
"resource": "https://mcp.example.com",
"authorization_servers": ["https://auth.example.com"],
"scopes_supported": ["files:read"]
}The client then reads the authorization server's metadata. It tries RFC 8414 (/.well-known/oauth-authorization-server) and OpenID Connect Discovery (/.well-known/openid-configuration), with path insertion first when the issuer has a path, such as https://auth.example.com/tenant1. Two checks there decide whether the flow may continue:
- The issuer must match. The
issuerin the document must be identical to the issuer used to build the URL. A document fetched fromattacker.examplethat claims"issuer": "https://honest.example"must be rejected. - PKCE must be advertised. If
code_challenge_methods_supportedis absent, the client must refuse to proceed. OpenID providers serving MCP must include the field, even though OIDC Discovery does not define it.
How does the client get a client ID?
Three ways, in a defined priority: pre-registration when client and server already know each other, Client ID Metadata Documents when they do not, and Dynamic Client Registration as a fallback. Version 2026-07-28 marks Dynamic Client Registration (RFC 7591) as deprecated, kept for authorization servers that do not support metadata documents yet.
With a Client ID Metadata Document, the client_id is itself an HTTPS URL with a path, such as https://app.example.com/oauth/client-metadata.json. The authorization server fetches it, validates the metadata and the redirect_uris, and advertises support with client_id_metadata_document_supported. Because the server now fetches URLs a client hands it, the specification adds rules against abuse of that fetch and against localhost redirect impersonation, and lets the server apply trust policies to which client domains it accepts.
What must the client and the server check on the token?
The client generates PKCE parameters with S256, records the expected issuer next to the code verifier, and includes resource in both the authorization request and the token request, set to the canonical URI of the MCP server: https://mcp.example.com, or https://mcp.example.com/mcp when the path identifies the server, without a trailing slash or fragment. It sends the parameter even when the authorization server ignores it.
When the authorization code comes back, the client applies RFC 9207. If the authorization server advertises authorization_response_iss_parameter_supported, an iss must be present and equal to the recorded issuer, compared as a plain string with no normalization. An iss that is present is always compared. On a mismatch the client must not use the code, and must not even display the error text. The specification expects a future revision to make iss mandatory for authorization servers.
The server validates every access token as OAuth 2.1 §5.2 requires, and checks that it was issued for this server as audience. Invalid or expired tokens get a 401. Tokens travel only in the Authorization: Bearer header, on every request, never in the query string. Authorization servers should issue short-lived access tokens and must rotate refresh tokens for public clients. All authorization server endpoints use HTTPS, and redirect URIs are either localhost or HTTPS.
How should an MCP server handle scopes?
Keep scopes_supported to the minimum needed for basic use, and ask for more only when an operation needs it. The server should put the required scopes in the scope parameter of its 401. When a valid token lacks a scope at runtime, the server should answer 403 Forbidden with error="insufficient_scope", the scopes the operation needs, and the resource_metadata URL. It should list every missing scope in one challenge: asking for them one retry at a time forces several authorization rounds for a single action.
The client accumulates scopes. On a challenge it requests the union of what it already had and what the server asks for, retries a few times at most, then treats the failure as permanent. A server must honour scope hierarchies, where a broad scope implies narrower ones. And offline_access does not belong in a server's challenge or metadata: refresh tokens are the client's concern, not a resource requirement.
What is token passthrough, and how does it create a confused deputy?
Token passthrough is an MCP server accepting a token without checking it was issued for the server, then forwarding it to a downstream API. The specification forbids it, and the Security Best Practices list what it breaks: downstream rate limits and validation are bypassed, logs show the wrong identity, and a stolen token can use the MCP server as an exfiltration proxy. When the server calls an upstream API, it acts as an OAuth client of that API with a separate token from the upstream authorization server.
The confused deputy appears when an MCP server proxies a third-party API with one static client ID. The third-party authorization server remembers the user's consent for that client ID in a cookie. An attacker registers a new client dynamically with a redirect URI of their own and sends the user a crafted link. The consent screen is skipped because of the cookie, the authorization code lands on the attacker's server, and the attacker exchanges it for access as that user. The rule is that an MCP proxy using a static client ID must obtain the user's consent for each dynamically registered client before forwarding to the third-party authorization server.
How do you test an MCP server's authorization?
Run these from a terminal against a test deployment. The first three need no token. Tests 4 and 5 need a valid token from the same identity provider but issued for another API, which is exactly the case audience validation exists for.
mcp-auth-tests.shcurl and jqMCP=https://mcp.example.com/mcp
AS=https://auth.example.com
# 1. No token: expect 401 and a resource_metadata URL in WWW-Authenticate
curl -si -X POST "$MCP" -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | grep -i -E '^HTTP|www-authenticate'
# 2. Protected Resource Metadata: resource and authorization_servers present
curl -s https://mcp.example.com/.well-known/oauth-protected-resource | jq '{resource, authorization_servers, scopes_supported}'
# 3. Authorization server metadata: issuer identical to $AS, S256 listed
curl -s "$AS/.well-known/oauth-authorization-server" | jq '{issuer, code_challenge_methods_supported,
client_id_metadata_document_supported, authorization_response_iss_parameter_supported}'
# 4. A valid token issued for another resource: expect 401
curl -si -X POST "$MCP" -H "Authorization: Bearer $TOKEN_FOR_OTHER_API" \
-H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | head -1
# 5. Token in the query string: expect a refusal, never a 200
curl -si -X POST "$MCP?access_token=$TOKEN" -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | head -1| Test | Expected result | Rule |
|---|---|---|
| No token on a protected request | 401 with WWW-Authenticate: Bearer resource_metadata="..." | Discovery, RFC 9728 §5.1 |
| Protected Resource Metadata | Served at the well-known URI; resource is the canonical server URI; authorization_servers lists at least one server | MUST implement RFC 9728 |
| Authorization server metadata | issuer identical to the URL used to fetch it; code_challenge_methods_supported includes S256 | Clients MUST refuse otherwise |
| Token issued for another API | 401. The server must check the audience | MUST validate audience (RFC 8707) |
| Expired or tampered token | 401 | OAuth 2.1 §5.3 |
| Token in the URL query string | Refused | MUST NOT be in the query string |
| Valid token, missing scope | 403 with error="insufficient_scope" and every scope the operation needs | SHOULD, RFC 6750 §3.1 |
| Calls to the upstream API | The server uses its own upstream token, never the one it received | MUST NOT pass through |
| Redirect URIs | Only localhost or HTTPS; the authorization server rejects others | MUST |
Test 8 cannot be seen from outside: read the server code or the upstream API's logs and check which token reaches it. Then check one tool at a time with a token that has the scope for another tool. A server that validates the token but not the scope per tool passes tests 1 to 6 and still lets a read-only token write.
Which MCP authorization mistakes come up most?
- Publishing every possible scope in scopes_supported, or a wildcard scope such as *, all or full-access.
- Returning the whole scope catalog in every challenge instead of the scopes the operation needs.
- Trusting the scopes claimed in a token without server-side authorization logic per tool.
- Validating the token signature but not its audience, so a token for any API of the same tenant works.
- Forwarding the client's token to the upstream API instead of exchanging it.
- Accepting tokens from more than one issuer without keeping registrations and tokens separate per authorization server.
- Adding OAuth to a stdio server. The spec says stdio implementations should not follow this flow and should read credentials from the environment.
The first three come from the specification's own list of scope-minimization mistakes. The others are the checks that most often separate a server that implements OAuth from one that enforces it. Authorization is also only one layer: a correctly scoped token still lets the agent call the tool with whatever arguments a poisoned document suggests. That part is covered under OWASP ASI02 and ASI03.
