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:<port>/callback; refusing loopback URIs makes login impossible. aud.
Verify 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.
When 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.
If 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 covers deployment, and MCP stdio vs remote 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.