MCP Authentication Explained: OAuth 2.1 for Remote MCP Servers A developer detailed how remote Model Context Protocol (MCP) servers authenticate callers using OAuth 2.1 with PKCE, metadata discovery, and bearer tokens, walking through the exact requests a client makes. The writeup covers the 401 challenge with a WWW-Authenticate header, protected-resource and authorization-server metadata documents, dynamic client registration for unregistered MCP clients, and the authorization-code exchange with a code verifier. It argues that the query-string token shortcut fails security review and that implementing the standard flow once makes Claude Desktop, Cursor, and VS Code work without custom integrations. A local MCP server launched over stdio inherits whatever credentials are already on your machine. Your shell has AWS PROFILE , DOCKER HOST , and a dozen tokens in ~/.config , and the server just uses them. That is why most teams discover MCP authentication late: the first server that needs to be shared across a company has no shell to inherit from. A remote MCP server speaks Streamable HTTP, sits behind a real hostname, and must authenticate every caller. The Model Context Protocol defines how: OAuth 2.1 with PKCE, metadata discovery, and bearer tokens. This article walks through the flow end to end, with the exact requests a client makes and the configuration that makes Claude Desktop, Cursor, and VS Code accept your server without manual workarounds. The tempting shortcut is https://mcp.example.com/mcp?token=... . It works for five minutes and fails every security review afterward: Referer headers. MCP clients expect the standard flow. Implement it once and every compliant client works without a custom integration. A protected MCP resource returns 401 Unauthorized with a WWW-Authenticate header pointing at the authorization server metadata: HTTP/1.1 401 Unauthorized WWW-Authenticate: Bearer resource metadata="https://mcp.example.com/.well-known/oauth-protected-resource" The protected-resource document tells the client where authorization happens and which audience the token must carry: { "resource": "https://mcp.example.com/mcp", "authorization servers": "https://auth.example.com" , "bearer methods supported": "header" , "scopes supported": "tools:read", "tools:run", "admin" } The client then fetches the authorization server metadata, conventionally served at /.well-known/oauth-authorization-server : { "issuer": "https://auth.example.com", "authorization endpoint": "https://auth.example.com/authorize", "token endpoint": "https://auth.example.com/token", "registration endpoint": "https://auth.example.com/register", "code challenge methods supported": "S256" , "response types supported": "code" , "grant types supported": "authorization code", "refresh token" , "token endpoint auth methods supported": "none", "client secret basic" } If you run an identity provider already, Auth0, Okta, Keycloak, and AWS Cognito all expose these documents. You are configuring routes, not writing an auth server. MCP clients are not pre-registered apps. On first connect they call the registration endpoint and receive a client ID: POST /register HTTP/1.1 Content-Type: application/json { "client name": "Claude Desktop", "redirect uris": "http://127.0.0.1:6273/callback" , "grant types": "authorization code", "refresh token" , "response types": "code" , "token endpoint auth method": "none" } { "client id": "mcp-local-9f3a2c", "client secret expires at": 0 } Public clients use no secret. That is deliberate: a desktop app cannot keep one, so OAuth 2.1 leans on PKCE instead. If your provider disables dynamic registration, issue a client ID out of band and hand it to users in the MCP URL configuration; everything else in the flow stays the same. The client generates a code verifier and its SHA-256 challenge, then opens the browser: https://auth.example.com/authorize ?response type=code &client id=mcp-local-9f3a2c &redirect uri=http://127.0.0.1:6273/callback &scope=tools:read%20tools:run &state=8xZ1... &code challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM &code challenge method=S256 After the user consents, the browser redirects to the loopback address with a code. The client exchanges it, proving possession of the verifier: curl -X POST https://auth.example.com/token \ -d grant type=authorization code \ -d client id=mcp-local-9f3a2c \ -d code=SplxlOBeZQQYbYS6WxSbIA \ -d redirect uri=http://127.0.0.1:6273/callback \ -d code verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r wW1gFWFOEjXk { "access token": "eyJhbGciOiJSUzI1NiIs...", "token type": "Bearer", "expires in": 900, "refresh token": "v1.MTQwYzI3...", "scope": "tools:read tools:run" } From then on, every JSON-RPC request to the MCP endpoint carries the bearer token: POST /mcp HTTP/1.1 Authorization: Bearer eyJhbGciOiJSUzI1NiIs... Content-Type: application/json Accept: application/json, text/event-stream {"jsonrpc":"2.0","id":1,"method":"tools/list"} Access tokens should live minutes, not weeks. The refresh token is the long-lived credential, and it can be revoked server-side without touching the client. Flat read/write scopes age badly once a server exposes twenty tools across three services. Scope by capability and enforce per call: | Scope | What the agent may do | |---|---| | tools:read | List tools and read their input schemas | | tools:run:safe | Call read-only tools GET-equivalent | | tools:run:write | Call tools that mutate state | | admin | Manage the server itself | Map each tool to a scope when you register it, and reject a call with a structured error rather than letting the agent discover the boundary by breaking something: { "jsonrpc": "2.0", "id": 7, "error": { "code": -32003, "message": "token lacks scope tools:run:write", "data": { "required scope": "tools:run:write" } } } After wiring several internal servers, the same four issues account for nearly every support ticket: issuer field, scheme and host included. http://127.0.0.1: