{"slug": "mcp-authentication-explained-oauth-2-1-for-remote-mcp-servers", "title": "MCP Authentication Explained: OAuth 2.1 for Remote MCP Servers", "summary": "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.", "body_md": "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.\n\nA 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.\n\nThe tempting shortcut is `https://mcp.example.com/mcp?token=...`. It works for five minutes and fails every security review afterward:\n\n`Referer` headers.\nMCP clients expect the standard flow. Implement it once and every compliant client works without a custom integration.\n\nA protected MCP resource returns `401 Unauthorized` with a `WWW-Authenticate` header pointing at the authorization server metadata:\n\n```\nHTTP/1.1 401 Unauthorized\nWWW-Authenticate: Bearer resource_metadata=\"https://mcp.example.com/.well-known/oauth-protected-resource\"\n```\n\nThe protected-resource document tells the client where authorization happens and which audience the token must carry:\n\n```\n{\n  \"resource\": \"https://mcp.example.com/mcp\",\n  \"authorization_servers\": [\"https://auth.example.com\"],\n  \"bearer_methods_supported\": [\"header\"],\n  \"scopes_supported\": [\"tools:read\", \"tools:run\", \"admin\"]\n}\n```\n\nThe client then fetches the authorization server metadata, conventionally served at `/.well-known/oauth-authorization-server`:\n\n```\n{\n  \"issuer\": \"https://auth.example.com\",\n  \"authorization_endpoint\": \"https://auth.example.com/authorize\",\n  \"token_endpoint\": \"https://auth.example.com/token\",\n  \"registration_endpoint\": \"https://auth.example.com/register\",\n  \"code_challenge_methods_supported\": [\"S256\"],\n  \"response_types_supported\": [\"code\"],\n  \"grant_types_supported\": [\"authorization_code\", \"refresh_token\"],\n  \"token_endpoint_auth_methods_supported\": [\"none\", \"client_secret_basic\"]\n}\n```\n\nIf 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.\n\nMCP clients are not pre-registered apps. On first connect they call the registration endpoint and receive a client ID:\n\n```\nPOST /register HTTP/1.1\nContent-Type: application/json\n\n{\n  \"client_name\": \"Claude Desktop\",\n  \"redirect_uris\": [\"http://127.0.0.1:6273/callback\"],\n  \"grant_types\": [\"authorization_code\", \"refresh_token\"],\n  \"response_types\": [\"code\"],\n  \"token_endpoint_auth_method\": \"none\"\n}\n{ \"client_id\": \"mcp-local-9f3a2c\", \"client_secret_expires_at\": 0 }\n```\n\nPublic 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.\n\nThe client generates a code verifier and its SHA-256 challenge, then opens the browser:\n\n```\nhttps://auth.example.com/authorize\n  ?response_type=code\n  &client_id=mcp-local-9f3a2c\n  &redirect_uri=http://127.0.0.1:6273/callback\n  &scope=tools:read%20tools:run\n  &state=8xZ1...\n  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM\n  &code_challenge_method=S256\n```\n\nAfter the user consents, the browser redirects to the loopback address with a code. The client exchanges it, proving possession of the verifier:\n\n```\ncurl -X POST https://auth.example.com/token \\\n  -d grant_type=authorization_code \\\n  -d client_id=mcp-local-9f3a2c \\\n  -d code=SplxlOBeZQQYbYS6WxSbIA \\\n  -d redirect_uri=http://127.0.0.1:6273/callback \\\n  -d code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk\n{\n  \"access_token\": \"eyJhbGciOiJSUzI1NiIs...\",\n  \"token_type\": \"Bearer\",\n  \"expires_in\": 900,\n  \"refresh_token\": \"v1.MTQwYzI3...\",\n  \"scope\": \"tools:read tools:run\"\n}\n```\n\nFrom then on, every JSON-RPC request to the MCP endpoint carries the bearer token:\n\n```\nPOST /mcp HTTP/1.1\nAuthorization: Bearer eyJhbGciOiJSUzI1NiIs...\nContent-Type: application/json\nAccept: application/json, text/event-stream\n\n{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}\n```\n\nAccess 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.\n\nFlat `read/write` scopes age badly once a server exposes twenty tools across three services. Scope by capability and enforce per call:\n\n| Scope | What the agent may do | \n|---|---|\n| `tools:read` | List tools and read their input schemas | \n| `tools:run:safe` | Call read-only tools (GET-equivalent) | \n| `tools:run:write` | Call tools that mutate state | \n| `admin` | Manage the server itself | \n\nMap 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:\n\n```\n{\n  \"jsonrpc\": \"2.0\",\n  \"id\": 7,\n  \"error\": {\n    \"code\": -32003,\n    \"message\": \"token lacks scope tools:run:write\",\n    \"data\": { \"required_scope\": \"tools:run:write\" }\n  }\n}\n```\n\nAfter wiring several internal servers, the same four issues account for nearly every support ticket:\n\n`issuer` field, scheme and host included.`http://127.0.0.1:<port>/callback`; refusing loopback URIs makes login impossible.` aud`.\nVerify the whole loop without an AI client first. `curl` the metadata documents, run the authorization flow in a browser, and call `initialize` and `tools/list` with the token. The MCP Inspector can drive the OAuth dance interactively once the raw requests work.\n\nWhen a team publishes an OpenAPI document as a hosted MCP server, authentication is the difference between a demo and infrastructure: the documentation stays public, while the tools that touch real systems sit behind OAuth with per-user scopes and audit logs. Publishing that endpoint is a build step from one spec, not a second implementation to secure.\n\nIf you want to see the hosted end of this flow, the walkthrough in [publishing API docs and an MCP endpoint from one spec, on your own domain](https://www.powerduck.com/blog/publish-api-docs-mcp-custom-domain/) covers deployment, and [MCP stdio vs remote transports](https://www.powerduck.com/blog/mcp-stdio-vs-remote-http-transports/) explains when hosting is even the right call. You can also try the local-first workflow, where none of this auth surface exists because the server runs on your machine, in the [online demo](https://www.powerduck.com/demo/).", "url": "https://wpnews.pro/news/mcp-authentication-explained-oauth-2-1-for-remote-mcp-servers", "canonical_source": "https://dev.to/jeff_pdc/mcp-authentication-explained-oauth-21-for-remote-mcp-servers-ak8", "published_at": "2026-10-03 22:57:48+00:00", "updated_at": "2026-10-03 23:07:48.075472+00:00", "lang": "en", "topics": ["agent-protocols", "ai-agents", "ai-tools", "developer-tools"], "entities": ["Model Context Protocol", "Claude Desktop", "Cursor", "VS Code", "Auth0", "Okta", "Keycloak", "AWS Cognito"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/mcp-authentication-explained-oauth-2-1-for-remote-mcp-servers", "markdown": "https://wpnews.pro/news/mcp-authentication-explained-oauth-2-1-for-remote-mcp-servers.md", "text": "https://wpnews.pro/news/mcp-authentication-explained-oauth-2-1-for-remote-mcp-servers.txt", "jsonld": "https://wpnews.pro/news/mcp-authentication-explained-oauth-2-1-for-remote-mcp-servers.jsonld"}}