MCP authentication explained: OAuth, scopes, and safe token handling
MCP authorization is OAuth 2.1, applied at the transport level, for HTTP-based servers only. A protected MCP server acts as an OAuth resource server, the client acts as an OAuth client, and a separate authorization server issues the tokens. Local stdio servers do not use this at all — they take credentials from the process environment.
Nothing about the flow is novel. What is easy to get wrong is the part that is specific to MCP: binding a token to one server, requesting the narrowest scopes, validating the issuer before you spend an authorization code, and enforcing permissions on the server rather than in the tool list.
When authorization applies
Authorization is optional in MCP. When it is used:
- HTTP-based transports should conform to the authorization specification.
- stdio implementations should not. They retrieve credentials from the environment, the way any local process does. There is no OAuth flow because there is no network boundary to authorize across.
- Other transports must follow established security practice for their own protocol.
If you are choosing between the two, see MCP stdio vs. Streamable HTTP; the credential model is one of the real differences between them.
The flow, end to end
The specification composes a selected subset of established RFCs rather than inventing a scheme. The sequence is:
- Unauthenticated request. The client calls the MCP endpoint with no token.
- Challenge. The server responds
401 Unauthorizedwith aWWW-Authenticateheader carrying aresource_metadataURL and, ideally, ascopeparameter. - Protected resource metadata. The client fetches that metadata document, which names the server's authorization server(s). MCP servers must implement RFC 9728 for this, and clients must use it for discovery.
- Authorization server metadata. The client discovers endpoints and capabilities through RFC 8414 or OpenID Connect Discovery. Servers must provide at least one; clients must support both.
- Client identity. Via a Client ID Metadata Document, a pre-registered client ID, or dynamic client registration.
- Authorization request. With PKCE parameters, the
resourceparameter, and the selected scopes. The client records the expected issuer before redirecting. - Callback. The client validates the returned
issagainst the recorded issuer, then exchanges the code with the verifier and the sameresourceparameter. - Authenticated requests.
Authorization: Bearer <token>on every request.
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
scope="files:read"
Client registration: three mechanisms, one preference order
A client needs a client ID before it can start. The specification names three ways to get one, and the order matters because it has shifted.
| Mechanism | Status |
|---|---|
| Client ID Metadata Documents | Preferred. Servers and clients should support it. The client uses an HTTPS URL as its client_id; the authorization server fetches the metadata from that URL and validates it, including redirect_uris. |
| Pre-registration | Fine when you control both sides and can register out of band. |
| Dynamic Client Registration (RFC 7591) | Deprecated, retained for backwards compatibility with authorization servers that do not support Client ID Metadata Documents. |
If you are building a client today, implement Client ID Metadata Documents and treat DCR as the fallback, not the default.
The resource parameter is not optional
This is the requirement most often skipped, and it is the one that prevents a stolen token from being replayed elsewhere.
Clients must implement Resource Indicators for OAuth 2.0 (RFC 8707). The resource parameter must
appear in both the authorization request and the token request, must identify the MCP server the token
is for, and must use that server's canonical URI. Clients must send it regardless of whether the
authorization server supports it.
Valid canonical URIs look like https://mcp.example.com/mcp, https://mcp.example.com, or
https://mcp.example.com:8443. Invalid ones omit the scheme or carry a fragment. Prefer the form
without a trailing slash, and use the most specific URI you can when one host serves several MCP
servers under different paths.
&resource=https%3A%2F%2Fmcp.example.com%2Fmcp
The server side of the same rule: an MCP server must validate that access tokens were issued specifically for it as the intended audience, must only accept tokens valid for its own resources, and must not accept or transit any other tokens. Clients must not send a server a token issued by anyone other than that server's authorization server.
Taken together, this is what closes the confused-deputy hole where a token minted for one service is handed to another that happily uses it.
Validate the issuer before you spend the code
Before redirecting the user-agent, the client must record the issuer value from the validated
authorization-server metadata, stored alongside the PKCE verifier and state for that request. On the
callback, it must validate the returned iss before transmitting the authorization code to any token
endpoint.
authorization_response_iss_parameter_supported |
iss in response |
Client action |
|---|---|---|
true |
present | Compare to the recorded issuer, simple string comparison |
true |
absent | Reject the response |
false or absent |
present | Compare to the recorded issuer |
false or absent |
absent | Proceed |
Two details decide whether this actually protects anything. The comparison must be a simple string
comparison — no scheme or host case folding, no default-port elision, no trailing-slash or
percent-encoding normalisation after URL-decoding. And it applies to error responses too: on a
mismatch the client must not act on or display error, error_description, or error_uri.
Authorization servers should emit iss and advertise it. The specification signals that inclusion is
expected to become a must in a future revision, so implement it now on both sides.
Scopes: ask for less, then ask again
The specification builds in a least-privilege path, and using it is mostly a matter of not ignoring what the server tells you.
Initial selection. In priority order: use the scope parameter from the WWW-Authenticate header
of the 401 if present; otherwise use scopes_supported from the protected resource metadata; omit
scope entirely if neither exists.
scopes_supported is meant to be the minimal set needed for basic functionality, not a catalogue of
everything the server can do. Additional permissions are requested incrementally.
Clients must treat the scopes in a challenge as authoritative for the current operation, and must not
assume any set relationship between them and scopes_supported — the challenge may be a subset, a
superset, or neither.
Step-up at runtime. When a valid token lacks a permission, the server should respond:
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope",
scope="files:write",
resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
error_description="File write permission required for this operation"
The client then parses the challenge, computes the union of its previously requested scopes and the challenged ones, re-authorizes with that set, and retries. The union step is what stops step-up from silently dropping permissions the client still needs for other operations — scope accumulation is the client's responsibility, not the server's.
Two obligations on the server side are worth stating plainly. Include all scopes needed for the current operation in a single challenge; drip-feeding one missing scope at a time forces a user through repeated authorization round trips for one action. And account for scope hierarchies, where a broader scope implies narrower ones, when deciding whether a token is sufficient.
Clients should implement retry limits and track upgrade attempts, so a permanently insufficient permission fails cleanly instead of looping.
Handling the token itself
- Send it in the
Authorizationrequest header, on every request. There is no session that remembers it. - Never put an access token in a URI query string.
- Keep refresh tokens confidential in transit and at rest. Clients that want them should include
refresh_tokenin theirgrant_typesand may addoffline_accessto the scope request when the authorization server advertises it — but must not assume refresh tokens will be issued. - Protected resources should not advertise
offline_accessin aWWW-Authenticatechallenge or inscopes_supported, because a refresh token is a client convenience, not a resource requirement.
Invalid or expired tokens must receive 401. The three status codes carry distinct meanings and
should not be used interchangeably:
| Status | Meaning |
|---|---|
400 |
Malformed authorization request |
401 |
Authorization required, or token invalid |
403 |
Valid token, insufficient scope or permissions |
Where enforcement actually has to live
The advertised list of tools, resources, or prompts may vary by the authorization presented on the request — returning only what the caller's granted scopes permit is explicitly allowed, because credentials are per-request input rather than connection state. The same lists must not vary per-connection or as a side effect of other requests.
But a filtered list is not access control. A caller who knows a tool name can call it. Authorization must be checked in the handler that performs the action, every time, against the token presented on that request.
The same applies to any state handle your tools return. Because MCP has no protocol-level session, servers that need continuity hand back explicit handles — a cart ID, a transaction ID. For an authenticated server, a handle is a name, not a capability: validate the caller's authorization against it on every call. For an unauthenticated server, where the handle necessarily is a bearer token, generate it with sufficient entropy and give it a bounded lifetime.
Troubleshooting
Repeated 401s after a successful sign-in. Usually an audience mismatch: the token was issued for a
different resource, or the resource parameter did not match the server's canonical URI. Check the
trailing slash and the path.
403 on one tool, 200 on the rest. An insufficient-scope challenge that the client is not acting
on. Read the WWW-Authenticate header rather than the status code alone.
The callback never completes. A redirect URI that does not match what was registered, or a
callback port the client did not actually bind. For Client ID Metadata Documents, confirm the
redirect_uris in the document at your client_id URL.
Scopes shrink after a step-up. The client re-authorized with only the challenged scope instead of the union. This is the most common step-up bug and it presents as previously working tools starting to fail.
Everything works locally and fails when hosted. stdio took credentials from the environment; nothing in that path exercised OAuth. Treat the hosted deployment as a new integration.
An MCP server asks for a token issued by someone else. Stop. A server must not accept or transit tokens other than its own. Treat the request as a finding, and vet the server before going further.
Before you connect a work account
- The server publishes protected resource metadata and challenges with a
scopeyou can read. - The scopes requested match what the server's documentation says it does.
- Write, delete, send, and publish permissions are absent unless you need them today.
- The token is bound to this server by a
resourceparameter. - You know which account the grant is against, and you can revoke it at the provider.
- You have tested a read-only operation before granting anything that changes state.
Revocation is the step people skip. Removing a server from a client stops that client from calling it; it does not necessarily end a grant the provider still honours. Revoke at the source.
Next step: Review an MCP server's authentication and permissions on LLM Mart before connecting a work account, and check the scopes it asks for against the ones it actually needs.
Sources
Comments (0)
Sign in to join the conversation.
No comments yet.