DEV Community

Yimmie Honrodt
Yimmie Honrodt

Posted on

OAuth 2.1 for MCP servers, done properly

Authorization is formally optional in MCP. That wording misleads people. It means you may run an unprotected server; it does not mean you get to half-implement a protected one. The moment you do protect an HTTP transport, the spec fills up with MUSTs, and most teams meet maybe half of them: pull in a library, wire up a login, ship it, leave three or four gaps that a scanner finds in minutes.

Here is what "properly" looks like, with a check for each point. One thing to know if MCP is new to you: protocol revisions are named after dates, so 2026-07-28 below is a version string and not a deadline. It is the current revision and the one this is written against.

Start from the right mental model

Your MCP server is not the login screen. It is a resource server. It receives an access token that some authorization server issued, and its job is to verify that token and stay inside what the token allows. Nearly every mistake below comes from blurring that line.

1. Verify all four token properties

A token is only worth trusting if you check:

  • Signature, against the IdP's JWKS.
  • Issuer, matching the IdP you expect.
  • Audience, matching your own canonical resource URI. This is the check that stops a token minted for some other service.
  • Expiry, with a small clock tolerance rather than an open-ended one.

Skip any one of the four and you have built a lock that opens for the wrong keys.

2. PKCE with S256

The wording in OAuth 2.1 leaves little room: clients "MUST use code_challenge and code_verifier and authorization servers MUST enforce their use", and "if the client is capable of using S256, it MUST use S256, as S256 is Mandatory To Implement (MTI) on the server."

plain is still in the spec, for clients that genuinely cannot hash and know out of band that the server accepts it. That is a constrained device, not your API.

Ten-second check: fetch the authorization server metadata and confirm that code_challenge_methods_supported contains "S256".

3. Publish the metadata, and expect two flavours of it

Clients discover where to authenticate through metadata, and this is one of the places the current revision moved:

  • The MCP server MUST implement OAuth 2.0 Protected Resource Metadata (RFC 9728) at /.well-known/oauth-protected-resource, and point at its authorization server or servers.
  • The authorization server MUST provide at least one of RFC 8414 authorization server metadata or OpenID Connect Discovery. Clients have to support both, so if you build a client, do not hardcode /.well-known/oauth-authorization-server and call it done.

Missing metadata means clients cannot do discovery at all. It is also a reliable smell that auth was bolted on at the end.

4. Say which scopes you want, in the 401

Issue one scope per capability, notes:read and notes:write rather than one scope for everything, and enforce them on every tool call rather than only at login. A read-only token must not be able to write, however politely the client asks.

The part people miss is the return channel. A 401 should carry a WWW-Authenticate header with the resource_metadata URI, and it SHOULD carry a scope parameter naming what this operation needs. At runtime, when a valid token is simply not enough, the answer is a 403 with error="insufficient_scope" and the required scopes, so the client can step up instead of guessing. Emit every scope the operation needs in one challenge; drip-feeding them one per round trip turns a single operation into three browser redirects.

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"
Enter fullscreen mode Exit fullscreen mode

5. Bind the token to you, and validate the issuer on the way back

Two requirements that are easy to overlook because they live on opposite ends of the flow.

Clients MUST send the resource parameter (RFC 8707) in both the authorization request and the token request, naming the canonical URI of the server the token is for. That is what makes audience validation possible on your side at all.

And on the authorization response, clients MUST validate the iss parameter (RFC 9207) against the issuer they recorded before the redirect, before the code goes anywhere near a token endpoint. Compare it as a plain string. No case folding, no trailing-slash normalisation, no default-port elision. This is the mix-up defence, and normalising the comparison quietly removes it.

While you are in that area: Dynamic Client Registration is now deprecated and retained for backwards compatibility. Client ID Metadata Documents are the mechanism to reach for.

6. Never pass the token through

Do not forward an incoming token to a downstream API, and do not accept a token that was not issued for you. Use your own credentials for downstream calls, or a proper token exchange. The spec puts it plainly: servers "MUST NOT accept or transit any other tokens."

Passthrough bypasses authorization and it poisons the audit trail, because the log then shows your server as the actor and never the caller. That failure has its own name, the confused deputy, and its own article.

What it looks like when it is right

In my reference server the tenant and the scopes come only from the verified token. Unauthenticated calls get a 401 with a WWW-Authenticate header pointing at the resource metadata. The authorization server advertises S256. Every tool checks its own scope before it runs.

Running the scanner against it, the auth and PKCE checks pass by construction rather than by luck. Both are public if you want to read the code:

Top comments (0)