How an MCP Client Actually Authenticates Over HTTP
A hop-by-hop look at the OAuth 2.1 dance that runs before the first tools/list
In my last post I said the HTTP story for MCP is OAuth 2.1, and left it there. That glosses over a lot. The happy path hides a surprisingly long discovery dance: a client doesn’t just “have a token” — it has to find the metadata, register itself (or not), prove it owns a client, and only then retry the request that started everything.
The spec tells you how this should work. I wanted to see how it actually works — including the parts where the spec is silent and real clients have to improvise. So I built a zero-dependency playground with two servers in one Node process:
- An MCP resource server (
http://localhost:3001/mcp) — minimal streamable-HTTP MCP server. - An OAuth authorization server (
http://localhost:3002) — discovery metadata, Dynamic Client Registration, authorization + token endpoints.
Every request and response — headers included — prints to the console and lands in a ring buffer. Point a real MCP client at it, and you watch the whole handshake happen hop by hop. This post is what the logs taught me.
The happy path
Here’s the flow an MCP client runs when it connects to an HTTP server for the first time. It’s longer than you’d think.
1. POST the request, get a 401
The client optimistically POSTs initialize with no token:
POST /mcp HTTP/1.1
Content-Type: application/json
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{}}}
The server answers 401 with a WWW-Authenticate challenge:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="http://localhost:3001/.well-known/oauth-protected-resource/mcp", scope="mcp"
That resource_metadata parameter is the client’s first hint about where to go.
2. Fetch the protected resource metadata
The challenge points to an RFC 9728 protected-resource metadata document:
GET /.well-known/oauth-protected-resource/mcp HTTP/1.1
{
"resource": "http://localhost:3001/mcp",
"authorization_servers": ["http://localhost:3002"],
"scopes_supported": ["mcp", "tools:execute"],
"bearer_methods_supported": ["header"]
}
This is where the resource announces who it trusts. Note that it can list multiple authorization servers — this document is how a resource says “I don’t do auth myself, go talk to this AS.”
3. Discover the authorization server
Given the AS URL, the client fetches RFC 8414 metadata:
{
"issuer": "http://localhost:3002",
"authorization_endpoint": "http://localhost:3002/authorize",
"token_endpoint": "http://localhost:3002/token",
"registration_endpoint": "http://localhost:3002/register",
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post", "none"]
}
One field matters more than it looks like: registration_endpoint. If it’s present, the client can try to register itself dynamically.
4. Register (or use a preconfigured client)
If registration_endpoint exists, the client POSTs a Dynamic Client Registration request:
POST /register HTTP/1.1
Content-Type: application/json
{
"client_name": "demo-mcp-client",
"application_type": "native",
"redirect_uris": ["http://localhost:8899/callback"],
"grant_types": ["authorization_code"],
"token_endpoint_auth_method": "none"
}
If registration succeeds, it gets a fresh client_id — often with no secret for public/native clients. If it fails, or there’s no registration_endpoint at all, the client falls back to a client_id it was configured with ahead of time. More on that fallback below.
5. Run the authorization-code flow with PKCE
With a client_id in hand, the client builds an authorization URL:
GET /authorize?
response_type=code&
client_id=dcr_...&
redirect_uri=http://localhost:8899/callback&
scope=mcp+tools:execute&
code_challenge=...&code_challenge_method=S256&
resource=http://localhost:3001/mcp
This is a textbook public-client authorization code flow: PKCE S256 because there’s no client secret, plus a resource parameter so the AS issues a token scoped to the specific MCP server. The client validates the iss parameter on the redirect, then exchanges the code at the token endpoint.
6. Retry the MCP request
Finally, the original initialize is retried with the token:
POST /mcp HTTP/1.1
Authorization: Bearer mcp_...
Content-Type: application/json
200. The client can now do the real work.
The whole thing is easy to draw as a sequence:
sequenceDiagram
participant C as Client
participant M as MCP Server (3001)
participant A as Auth Server (3002)
C->>M: POST /mcp (no token)
M-->>C: 401 + WWW-Authenticate (resource_metadata)
C->>M: GET /.well-known/oauth-protected-resource/mcp
M-->>C: authorization_servers: [http://localhost:3002]
C->>A: GET /.well-known/oauth-authorization-server
A-->>C: metadata (registration_endpoint?, authorize, token)
alt DCR advertised
C->>A: POST /register
A-->>C: client_id, client_secret
else registration rejected or no DCR
Note over C: fall back to preconfigured client_id
end
C->>A: GET /authorize (PKCE S256 + resource)
A-->>C: 302 redirect_uri?code&state&iss
C->>A: POST /token (code + code_verifier)
A-->>C: access_token
C->>M: POST /mcp (Bearer token)
M-->>C: 200 JSON-RPC result
What the logs actually taught me
The happy path is spec-accurate. The interesting stuff is in the failure modes and the unspecified corners.
Finding 1: The 401 may or may not tell you where the metadata lives
RFC 9728 §5.1 lets the challenge carry resource_metadata, but it doesn’t have to. Some servers send only Bearer scope="mcp". When that parameter is missing, the client has to construct the well-known URI by inserting the MCP endpoint’s path into a fixed template:
<base_url>/.well-known/oauth-protected-resource/<remaining_path>
So an MCP server at https://example.com/mcp produces https://example.com/.well-known/oauth-protected-resource/mcp. It’s a guess — an educated one, baked into the MCP spec, but still a guess. The client has no way to know the server actually serves that path until it requests it.
To see it for yourself, flip off resource_metadata and the log shows the client doing exactly this derivation instead of trusting the header.
Finding 2: Metadata is a promise, not a guarantee
This is the big one. A registration_endpoint in the metadata only means the endpoint exists. It says nothing about whether your registration will be accepted.
Real servers advertise DCR but enforce a redirect-URI whitelist — Figma is a well-known example. A client registering its own callback gets:
HTTP/1.1 400 Bad Request
{"error":"invalid_redirect_uri","error_description":"redirect_uri not allowed by server policy"}
The metadata still advertised registration_endpoint. /register still responded. Registration still failed. A robust client therefore treats DCR as best-effort and falls back to a preconfigured client_id whenever registration is rejected. My demo client prints that fallback explicitly:
!! registration rejected (400)
5b. registration_endpoint was advertised but registration failed - falling back to preconfigured client_id
client_id=preconfigured-client, redirect_uri=http://localhost:8899/callback
If you’re building a client, plan for this path. If you’re building a server, be aware that advertising DCR sets an expectation you may not honor.
Finding 3: A preconfigured client_id skips DCR entirely
Many MCP clients let you configure a client_id for a server directly. When one exists, the client never calls /register. It goes straight from the AS metadata to authorization_endpoint + token_endpoint. This is the flow you get when a server turns DCR off, or when you’re integrating against an IdP that issues clients out-of-band.
Worth knowing because it changes your debugging frame: if you see a client that “just works” with a token, it may never have registered at all.
Finding 4: Scope checks happen at runtime, not just at login
Scope isn’t settled at the token exchange. In this playground, a token with mcp but not tools:execute can call initialize and tools/list fine — but calling tools/call gets:
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope", scope="tools:execute"
That’s a step-up authorization trigger, not a login failure. It’s the server saying “you’re authenticated, but you need a broader grant for this particular method.” It matters for MCP because tools are the sensitive surface — a server can hand out read-only access and still gate tool execution behind a finer-grained scope. If you’re consuming MCP servers, treat 403 insufficient_scope as an instruction to re-negotiate scope, not as a bug in your token.
A real trace
Here’s the entire handshake, condensed from a log captured when a real IDE client pointed at the playground. It shows the client negotiating 2025-11-25 down to the server’s 2025-06-18, registering dynamically, and getting a token:
MCP REQUEST POST /mcp -> 401
WWW-Authenticate: Bearer resource_metadata=".../.well-known/oauth-protected-resource/mcp", scope="mcp"
MCP REQUEST GET /.well-known/oauth-protected-resource/mcp -> 200
{"resource":"http://localhost:3001/mcp","authorization_servers":["http://localhost:3002"],...}
AUTH REQUEST GET /.well-known/oauth-authorization-server -> 200
{"issuer":"http://localhost:3002", ...,"registration_endpoint":"http://localhost:3002/register"}
AUTH REQUEST POST /register -> 201
{"client_id":"dcr_29fb191653353561","client_name":"Windsurf","redirect_uris":["http://127.0.0.1:8765/auth/callback"],"token_endpoint_auth_method":"none"}
AUTH REQUEST GET /authorize?...&code_challenge=...&code_challenge_method=S256&resource=http://localhost:3001/mcp -> 302
Location: .../auth/callback?code=...&state=...&iss=http%3A%2F%2Flocalhost%3A3002
AUTH REQUEST POST /token -> 200
{"access_token":"mcp_...","token_type":"Bearer","expires_in":3600,"scope":"mcp tools:execute"}
MCP REQUEST POST /mcp (Authorization: Bearer mcp_...) -> 200
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{}},"serverInfo":{"name":"understanding-mcp","version":"0.1.0"}}}
MCP REQUEST POST /mcp (tools/list) -> 200
{"tools":[{"name":"echo",...}]}
Every hop you’d expect is there: challenge, protected-resource discovery, AS discovery, DCR, PKCE authorize, token exchange, and finally the authenticated MCP calls. No client secrets anywhere — public client with token_endpoint_auth_method: none, secured entirely by PKCE.
Reproduce it yourself
The whole thing is a zero-dependency repo. No npm install, just Node 18+:
node server.js # terminal 1: watch every request/response
node client.js # terminal 2: drive the full flow end-to-end
Each of the four findings is a one-line toggle:
# Finding 1: 401 without resource_metadata -> client derives the well-known URI
WWW_AUTH_RESOURCE_METADATA=off node server.js
# Finding 2: DCR advertised but redirect URI not whitelisted -> client falls back
DCR_ALLOWED_REDIRECT_URIS='http://only-whitelisted.example/callback' node server.js
# Finding 3: no DCR at all -> preconfigured client_id only
AUTH_DCR=off node server.js
Or point a real MCP client at http://localhost:3001/mcp and watch it negotiate on its own — that’s how the trace above was captured. Both servers expose their log buffers at GET /__log if you want the JSON.
Wrap-up
The MCP auth story isn’t “send a token.” It’s a discovery protocol layered on top of OAuth 2.1 — challenge, two metadata documents, registration-or-preconfigured, PKCE, and scope checks that outlive the login. The spec covers the happy path well; the interesting engineering is in the fallbacks. Register best-effort, fall back to a configured client, validate iss, and treat a 403 insufficient_scope as a scope problem rather than a token problem.
I keep saying I’ll expand these notes as I dig deeper into MCP internals — this is the first real expansion. If there’s a corner you’d like me to instrument next (refresh tokens, client ID metadata documents, or per-tool scope step-up), the playground makes it easy to point at.