A 15-slide walkthrough
How an MCP Client Actually Authenticates
The OAuth 2.1 discovery dance that runs before the first tools/list — hop by hop, from a real logged handshake.
Press → or space to flip through
THE SETUP
It's a discovery protocol, not 'send a token'
A client doesn't just have a token — it has to find metadata, register itself (or not), prove it owns a client, and only then retry the request that started everything.
To watch every hop, I built a zero-dependency playground: two servers in one Node process, every request and response printed to a log.
MCP resource server
http://localhost:3001/mcp — minimal streamable-HTTP MCP server. Answers 401, serves metadata, runs tools.
OAuth authorization server
http://localhost:3002 — discovery metadata, Dynamic Client Registration, authorize + token endpoints.
Zero dependencies
No npm install. Point a real MCP client at it and watch the whole handshake happen hop by hop.
Ring-buffer logs
Every request and response — headers included — prints to the console and lands in a log buffer.
STEP 1 OF 6
POST the request, get a 401
- 1401
- 2resource
- 3discover
- 4register
- 5PKCE
- 6Bearer
- The client optimistically POSTs
initializewith no token. - The server answers
401with aWWW-Authenticatechallenge. Theresource_metadataparameter is the first hint about where to go.
STEP 2 OF 6
Fetch the protected-resource metadata
- 1401
- 2resource
- 3discover
- 4register
- 5PKCE
- 6Bearer
- The
resource_metadataURL from the 401 serves an RFC 9728 document. - It names the trust chain:
authorization_servers, supported scopes, and bearer methods. - This is a resource saying: "I don't do auth myself — go talk to this authorization server."
STEP 3 OF 6
Discover the authorization server
- 1401
- 2resource
- 3discover
- 4register
- 5PKCE
- 6Bearer
- Given the AS URL, the client fetches RFC 8414 metadata for
authorization_endpointandtoken_endpoint. - One field matters more than it looks:
registration_endpoint. If it exists, the client can try to register dynamically.
STEP 4 OF 6
Register — or use a preconfigured client
- 1401
- 2resource
- 3discover
- 4register
- 5PKCE
- 6Bearer
- If
registration_endpointexists, the client POSTs a Dynamic Client Registration (RFC 7591) request. - Success yields a fresh
client_id— often with no secret for public clients. - If registration fails — or there is no endpoint — it falls back to a preconfigured
client_id.
STEP 5 OF 6
Run the authorization-code flow with PKCE
- 1401
- 2resource
- 3discover
- 4register
- 5PKCE
- 6Bearer
- Textbook public-client flow: PKCE
S256because there is no secret, plus aresourceparameter so the token is scoped to the MCP server. - The client validates
isson the redirect, then exchanges the code at the token endpoint.
STEP 6 OF 6
Retry the request with the token
- 1401
- 2resource
- 3discover
- 4register
- 5PKCE
- 6Bearer
- The original
initializeis retried withAuthorization: Bearer …. 200. The client can now do the real work —tools/list,tools/call, and friends.
Finding
The 401 may not tell you where the metadata lives
- RFC 9728 lets the challenge carry
resource_metadata— but it does not have to. - When it's missing, the client constructs the well-known URI from a fixed template — an educated guess baked into the MCP spec.
- Flip it off in the playground and the log shows the client deriving the URI instead of trusting the header.
Finding
Metadata is a promise, not a guarantee
- A
registration_endpointonly means the endpoint exists — not that your registration will be accepted. - Real servers (Figma is famous for it) enforce redirect-URI whitelists.
/registerresponds, yet registration fails. - A robust client treats DCR as best-effort and falls back to a preconfigured
client_id.
Finding
A preconfigured client_id skips DCR entirely
- Configure a
client_idfor a server and/registeris never called. - The client jumps from AS metadata straight to
authorization_endpoint+token_endpoint. - Debugging frame shift: a client that "just works" with a token may never have registered at all.
Finding
Scope checks happen at runtime, not just at login
- A token with
mcpbut nottools:executecan callinitializeandtools/listfine. tools/call→403 insufficient_scope. That is a step-up trigger, not a login failure.- Treat it as an instruction to re-negotiate scope — not a bug in your token.
FROM THE LOGS
A real trace, start to finish
A real IDE client negotiating 2025-11-25 down to the server's 2025-06-18, registering dynamically, and getting a token:
TRY IT
Reproduce it yourself
Zero-dependency repo — no npm install, just Node 18+:
TAKEAWAYS
The MCP auth story is a discovery protocol
Challenge, two metadata documents, registration-or-preconfigured, PKCE, and scope checks that outlive the login. The spec covers the happy path; the interesting engineering is in the fallbacks.
Register best-effort
DCR is a fallback path, not a guarantee.
Fall back to a configured client
A preconfigured client_id skips /register entirely.
Validate iss
Public clients live and die by PKCE + issuer checks.
403 ≠ token bug
insufficient_scope means re-negotiate scope.
Prefer reading over flipping? Read the full written version →