OAuth for MCP servers, the parts you implement
- Spec
2026-07-28- Evidence
- E2Reproducedarm64
- Go SDK
github.com/modelcontextprotocol/go-sdk@v1.8.0- MCP Inspector
@modelcontextprotocol/inspector@2.12.0- Verified
MCP authorization is plain OAuth 2.1 with a few fixed choices. Your MCP server is the resource server: it checks tokens and tells clients where to get them. It does not log users in or issue tokens; an authorization server does that, such as your identity provider. Most of the work, and most of the rules, are on the client side.
This page explains the flow, shows the server’s part running, and ends with a checklist. We tested the resource-server half with the Go SDK. We did not run a full login against an authorization server.
Who needs it
Section titled “Who needs it”| Transport | What the spec says |
|---|---|
| Streamable HTTP | Authorization is optional, but if you do it, you should follow this flow. |
| stdio | You should not use this flow. A local server gets its credentials from environment variables. |
A stdio server that calls an API with a key needs no OAuth: put the key in the client’s env settings for that server.
The flow in five steps
Section titled “The flow in five steps”- The client calls without a token. The server answers
401 Unauthorizedwith aWWW-Authenticateheader that points to its metadata. - The client reads the protected resource metadata (RFC 9728). The document names the authorization server(s) and the scopes.
- The client reads the authorization server’s metadata and checks that it supports PKCE.
- The client identifies itself and the user logs in. In 2026-07-28 the client usually identifies itself with a Client ID Metadata Document. The user approves in a browser; the client exchanges the code for a token, using PKCE and naming your server in the
resourceparameter. - The client calls again with
Authorization: Bearer <token>on every request. The server checks that the token was issued for it.
The server’s part, running
Section titled “The server’s part, running”The Go SDK ships the two pieces a resource server needs: auth.RequireBearerToken, a middleware that sends the 401 challenge, and auth.ProtectedResourceMetadataHandler, which serves the metadata document.
// A Streamable HTTP MCP server that requires an OAuth bearer token.// It shows the resource-server half of MCP authorization: the 401 challenge and the// protected resource metadata document. It does not issue tokens; an authorization// server does that. The verifier below is a stand-in for local testing only.package main
import ( "context" "log" "net/http" "os" "time"
"github.com/modelcontextprotocol/go-sdk/auth" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/oauthex")
const ( // The canonical URI of this MCP server. Tokens must be issued for exactly this audience. resourceURL = "http://127.0.0.1:8333/mcp" metadataURL = "http://127.0.0.1:8333/.well-known/oauth-protected-resource/mcp")
type AddInput struct { A float64 `json:"a" jsonschema:"first number"` B float64 `json:"b" jsonschema:"second number"`}
type AddOutput struct { Sum float64 `json:"sum"`}
func add(ctx context.Context, req *mcp.CallToolRequest, in AddInput) (*mcp.CallToolResult, AddOutput, error) { return nil, AddOutput{Sum: in.A + in.B}, nil}
// verifyToken stands in for real validation. A real server verifies the token's signature// (or asks the authorization server to introspect it) and checks issuer, expiry and that// the audience is resourceURL. It must never accept a token issued for another service.func verifyToken(ctx context.Context, token string, req *http.Request) (*auth.TokenInfo, error) { if want := os.Getenv("DEMO_TOKEN"); want == "" || token != want { return nil, auth.ErrInvalidToken } return &auth.TokenInfo{Scopes: []string{"mcp:tools"}, Expiration: time.Now().Add(time.Hour)}, nil}
func main() { server := mcp.NewServer(&mcp.Implementation{Name: "hello-go-auth", Version: "1.0.0"}, nil) mcp.AddTool(server, &mcp.Tool{Name: "add", Description: "Add two numbers and return the sum."}, add) handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return server }, &mcp.StreamableHTTPOptions{Stateless: true})
// 401 responses carry WWW-Authenticate: Bearer resource_metadata="…", scope="…". requireToken := auth.RequireBearerToken(verifyToken, &auth.RequireBearerTokenOptions{ ResourceMetadataURL: metadataURL, Scopes: []string{"mcp:tools"}, })
mux := http.NewServeMux() mux.Handle("/mcp", requireToken(handler)) // Protected resource metadata (RFC 9728): tells clients which authorization server to use. mux.Handle("/.well-known/oauth-protected-resource/mcp", auth.ProtectedResourceMetadataHandler( &oauthex.ProtectedResourceMetadata{ Resource: resourceURL, AuthorizationServers: []string{"https://auth.example.com"}, ScopesSupported: []string{"mcp:tools"}, BearerMethodsSupported: []string{"header"}, }))
log.Printf("hello-go-auth: listening on %s", resourceURL) log.Fatal(http.ListenAndServe("127.0.0.1:8333", mux))}The verifyToken function is a stand-in that accepts one fixed token, so the example runs without an authorization server. Replace it with real validation; see the checklist below.
DEMO_TOKEN=local-test-token go run . &curl -si -X POST http://127.0.0.1:8333/mcp \ -H 'content-type: application/json' \ -H 'accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'Expected401 with a WWW-Authenticate header that names the metadata URL and the scope.
HTTP/1.1 401 UnauthorizedWww-Authenticate: Bearer resource_metadata="http://127.0.0.1:8333/.well-known/oauth-protected-resource/mcp", scope="mcp:tools"
no bearer tokenA wrong token got the same status and header, with the body invalid token.
curl -s http://127.0.0.1:8333/.well-known/oauth-protected-resource/mcp{"resource":"http://127.0.0.1:8333/mcp","authorization_servers":["https://auth.example.com"], "scopes_supported":["mcp:tools"],"bearer_methods_supported":["header"]}resource is the server’s canonical URI: the value clients put in the resource parameter, and the audience your tokens must carry. authorization_servers must list at least one server.
The metadata lives at the well-known path plus the MCP endpoint’s path (/.well-known/oauth-protected-resource/mcp for /mcp). Clients use the URL from the WWW-Authenticate header if there is one; otherwise they try that path first and then /.well-known/oauth-protected-resource at the root.
What the Inspector does
Section titled “What the Inspector does”With no token, npx @modelcontextprotocol/inspector@2.12.0 --cli http://127.0.0.1:8333/mcp … tried to start an interactive OAuth login and stopped, because it had no terminal or browser to use:
{"error":{"code":"auth_required","message":"Interactive OAuth requires a TTY on stdin or stderr (or MCP_AUTO_OPEN_ENABLED=true). For CI/non-interactive runs use --stored-auth-only."}}To test the server with a token you already have, pass it as a header:
npx -y @modelcontextprotocol/inspector@2.12.0 --cli http://127.0.0.1:8333/mcp \ --header "Authorization: Bearer local-test-token" --method tools/listThe Inspector also takes --client-id, --client-secret and --client-metadata-url for a real OAuth login.
How clients register: CIMD replaces DCR
Section titled “How clients register: CIMD replaces DCR”Before a client can start a login, the authorization server must know it. The spec lists the options in this order of preference:
- Pre-registration. The client already has a client ID for this authorization server.
- Client ID Metadata Documents (CIMD). The client’s ID is an HTTPS URL with a path, such as
https://app.example.com/oauth/client-metadata.json. The authorization server fetches that JSON (at leastclient_id,client_name,redirect_uris) and checks thatclient_idmatches the URL. Authorization servers advertise support withclient_id_metadata_document_supported: true. - Dynamic Client Registration (DCR), where the client registers itself at a
registration_endpoint. It is deprecated in 2026-07-28 and kept only for authorization servers without CIMD. - Ask the user to enter client details.
For you as a server author, this means choosing an authorization server that supports CIMD. You do not implement any of it in the MCP server.
Checklist for your MCP server
Section titled “Checklist for your MCP server”- Serve protected resource metadata, with
resourceset to the server’s canonical URI and at least one entry inauthorization_servers. - Answer requests without a valid token with
401andWWW-Authenticate: Bearer resource_metadata="…", plusscope="…"for the scopes needed. - Answer a token with too few scopes with
403anderror="insufficient_scope", listing every scope the operation needs in one challenge. - Validate every token: signature or introspection, issuer, expiry, and audience. Reject tokens issued for any other service.
- Never pass the client’s token on to an upstream API. If your tools call another API, get a separate token for it.
- Accept the token only from the
Authorizationheader, never from the query string. - Serve everything over HTTPS. Redirect URIs must be HTTPS or localhost.
- Keep tokens out of logs.
- Do not list
offline_accessinscopes_supportedor in the challenge; refresh tokens are not a resource requirement.
The client side has its own rules (PKCE with S256, refusing an authorization server whose metadata lacks code_challenge_methods_supported, sending resource in both the authorization and token requests, checking iss in the response). If you use an official SDK’s client, it implements them; if you write a client, read the security considerations in the spec.
Related
Section titled “Related”Sources
MCPDev is community documentation. It is not affiliated with the Model Context Protocol project or with the vendors of the clients it covers.