Remote MCP server with OAuth for Claude — a worked example
What actually happens when Claude connects to a remote MCP server with OAuth — the 401, protected resource metadata, client ID metadata documents, PKCE, tokens — traced through a real production server, with the pitfalls we hit on Cloudflare Workers.
Updated 2026-10-03 · CC BY 4.0
The MCP authorization specification is short, but it strings together half a dozen RFCs, and most guides stop at the diagram. This one follows a single real sign-in — Claude connecting to the Cairn Commons MCP server at https://cairn-commons.com/mcp — request by request, and then lists the three things that broke in production and how we fixed them. The server is a stateless Streamable HTTP endpoint running on a Cloudflare Worker; nothing here depends on that, except the pitfalls.
The cast
- Resource server: the MCP endpoint,
https://cairn-commons.com/mcp. - Authorization server: the same site; endpoints under
/oauth/…, consent page at/oauth/authorize. - Client: Claude (claude.ai, the desktop and mobile apps). It identifies itself with a client ID metadata document instead of registering.
- User: someone signed in to the site, who approves the connection once.
Step 1: the first request gets a 401 that points somewhere
Claude starts by calling the MCP endpoint without a token. The server answers 401 with a header that says where to learn about it:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://cairn-commons.com/.well-known/oauth-protected-resource/mcp"
That is RFC 9728 (OAuth 2.0 Protected Resource Metadata). Without this header, clients fall back to guessing well-known URLs, which works less reliably.
Step 2: protected resource metadata names the authorization server
GET /.well-known/oauth-protected-resource/mcp
{
"resource": "https://cairn-commons.com/mcp",
"authorization_servers": ["https://cairn-commons.com"],
"scopes_supported": ["read", "write"],
"bearer_methods_supported": ["header"]
}
The resource value matters later: tokens are bound to it.
Step 3: authorization server metadata lists the endpoints
GET /.well-known/oauth-authorization-server
{
"issuer": "https://cairn-commons.com",
"authorization_endpoint": "https://cairn-commons.com/oauth/authorize",
"token_endpoint": "https://cairn-commons.com/oauth/token",
"registration_endpoint": "https://cairn-commons.com/oauth/register",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none"],
"authorization_response_iss_parameter_supported": true,
"client_id_metadata_document_supported": true
}
(RFC 8414, abridged.) Two fields decide how the client identifies itself: registration_endpoint allows dynamic client registration (RFC 7591), and client_id_metadata_document_supported allows the newer alternative.
Step 4: the client identifies itself with a URL
With client ID metadata documents, the client_id is an https URL, and the server fetches it to learn the client's name and allowed redirect URIs. Claude's is https://claude.ai/oauth/mcp-oauth-client-metadata, which describes the client "Claude" with the redirect URI https://claude.ai/api/mcp/auth_callback. In Claude's connector settings this is the option Use Claude's published identity, the default.
The advantage over dynamic registration: the server never stores thousands of throwaway registrations, and the user sees a name the server has verified from the client's own domain. Our server validates that the document's client_id equals its URL, requires a non-empty redirect_uris, and caches the document for a day.
Clients without a published document register instead: POST /oauth/register with their name and redirect URIs, and receive a client_id. Only public clients are supported — no secrets — so PKCE carries the security.
Step 5: authorization request with PKCE and the resource
Claude opens the browser at the authorization endpoint:
GET /oauth/authorize?response_type=code
&client_id=https://claude.ai/oauth/mcp-oauth-client-metadata
&redirect_uri=https://claude.ai/api/mcp/auth_callback
&code_challenge=…&code_challenge_method=S256
&resource=https://cairn-commons.com/mcp
&scope=read write&state=…
The server refuses anything without an S256 code challenge, checks the redirect URI against the client's list exactly, and checks that resource (RFC 8707; the MCP specification requires clients to send it) is its own MCP endpoint: the token is issued for this server only. The user signs in if needed and sees a consent page naming the client and what it will be able to do. On approval, the browser is redirected back with a one-time code (valid for five minutes) and iss (RFC 9207), which lets the client confirm which server answered.
Step 6: code for tokens
Claude's backend exchanges the code:
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&code=…&code_verifier=…
&client_id=https://claude.ai/oauth/mcp-oauth-client-metadata
&redirect_uri=https://claude.ai/api/mcp/auth_callback
&resource=https://cairn-commons.com/mcp
The response carries an access token valid for an hour and a refresh token valid for 30 days. Refresh tokens rotate: each use returns a new one and invalidates the old. Tokens are stored only as hashes, appear in the user's account page as "OAuth: Claude", and can be revoked there or through /oauth/revoke (RFC 7009).
Step 7: calling tools
From now on every MCP request carries Authorization: Bearer …. Because the server is stateless, each JSON-RPC request is a standalone POST; a GET /mcp gets a 405 explaining that.
Three things that broke in production
1. The framework's CSRF check refused token requests. SvelteKit rejects cross-origin form posts by default, and an OAuth token request is a cross-origin form post (from Claude's servers, with no matching Origin). Every token exchange failed with 403 although our own route accepted it. The fix was to run the origin check ourselves for pages only, and leave API, MCP and OAuth routes to their own checks.
2. Workers reject redirect: "error". Fetching a client metadata document should not follow redirects (a redirect could point the server at an attacker's document). The obvious fetch(url, { redirect: "error" }) throws on Cloudflare Workers; redirect: "manual" plus refusing any 3xx response does the same job.
3. A CDN refused the server's fetch. The metadata document fetch from a Worker was blocked by the client's CDN. We keep a copy of the published documents of well-known clients as a fallback; the live document still wins whenever it loads.
Testing it yourself
The quickest check is to add the server to Claude under Customize → Connectors → Add custom connector and watch your server logs. To test without a client, walk the same steps with curl: request /mcp without a token, follow the metadata, and run a PKCE flow by hand. The MCP Inspector does the same interactively.
The full client list with setup steps is on the agent setup page. For the protocol details, see the MCP authorization specification.