# MCP Authentication Explained: OAuth 2.1 for Remote MCP Servers

> Source: <https://dev.to/jeff_pdc/mcp-authentication-explained-oauth-21-for-remote-mcp-servers-ak8>
> Published: 2026-10-03 22:57:48+00:00

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](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/).
