MCP Server Returns 401? How to Debug OAuth Discovery (RFC 9728, RFC 8414) for Claude, Cursor, and ChatGPT Connectors
Debug a failing MCP server OAuth flow for Claude, Cursor and ChatGPT: WWW-Authenticate, RFC 9728 and RFC 8414 metadata, PKCE and dynamic client registration.
You deployed a remote MCP server, added it to Claude, Cursor, or a ChatGPT connector, and the client says "authorization failed", "could not connect", or just spins. The server logs show a 401 and nothing else. This is the most common failure in remote MCP deployments, and it is almost always a discovery problem, not a token problem.
Before any token is issued, an MCP client has to discover where to authenticate. That discovery is a chain of four HTTP responses. If any link in the chain is wrong, the client gives up with a vague error. This post walks the chain in order, with a curl command for each link and the failure modes we see most often.
The Discovery Chain
- Client sends an unauthenticated request to the MCP endpoint.
- Server answers
401with aWWW-Authenticateheader that points to protected resource metadata (RFC 9728). - Client fetches that metadata, which names one or more authorization servers.
- Client fetches the authorization server's metadata (RFC 8414, or OpenID Connect discovery) to find the
authorization_endpoint,token_endpoint, and, if the client wants to self-register, theregistration_endpoint.
Only after step 4 does the OAuth authorization code flow begin. Steps 1–4 involve no user and no credentials, which means you can test every one of them from a terminal.
Step 1: Does the 401 Carry a Usable Challenge?
curl -si -X POST https://mcp.example.com/mcp \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"0"}}}'
You want a 401 and a header like:
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"
Common failures:
| Symptom | Cause | Fix |
|---|---|---|
401 with no WWW-Authenticate | Auth middleware rejects before your challenge logic runs | Emit the header from the same layer that returns the 401 |
resource_metadata is http:// | TLS terminates at a proxy and the app builds URLs from the internal scheme | Trust X-Forwarded-Proto or hard-code the public origin |
resource_metadata is a relative path | Header built from a route name | Use an absolute URL |
403 instead of 401 | Gateway treats missing credentials as forbidden | Return 401 for missing or invalid credentials; reserve 403 for insufficient scope |
200 with an HTML login page | A web-app auth redirect is sitting in front of the API route | Exempt the MCP path from browser redirects |
Step 2: Does the Protected Resource Metadata Resolve?
curl -si https://mcp.example.com/.well-known/oauth-protected-resource
Expect 200, content-type: application/json, and a body like:
{
"resource": "https://mcp.example.com/mcp",
"authorization_servers": ["https://auth.example.com"]
}
Failures to look for:
- SPA catch-all. A single-page app or CDN rule returns
index.htmlwith200for every unknown path. The status looks fine; the body is HTML, so JSON parsing fails. Always check thecontent-type, not just the status. - Path mismatch. If your resource lives at a path (
/mcp), RFC 9728 metadata can be served path-suffixed at/.well-known/oauth-protected-resource/mcp. The URL in yourresource_metadataparameter must point to wherever you actually serve it. resourcedoesn't match the server URL. Clients compare the metadata'sresourceto the URL they connected to and reject a mismatch. A trailing slash or a different hostname (internal vs public) is enough.- Redirects. A
301fromhttptohttps, or from a bare domain towww, on the well-known path breaks clients that don't follow redirects on discovery requests. - Empty
authorization_servers. The array must name at least one issuer.
Step 3: Does the Authorization Server Publish Metadata?
Take the first entry in authorization_servers and fetch both discovery documents. They are two separate documents and a spec-compliant issuer may serve either or both:
curl -si https://auth.example.com/.well-known/oauth-authorization-server
curl -si https://auth.example.com/.well-known/openid-configuration
If your issuer has a path component (https://auth.example.com/tenant1), the RFC 8414 path is inserted between the well-known prefix and the path: /.well-known/oauth-authorization-server/tenant1. OpenID Connect discovery instead appends: /tenant1/.well-known/openid-configuration. Mixing these up is a classic multi-tenant bug (Keycloak realms and Auth0 custom domains both hit it).
Check the response for:
issuerexactly equal to the URL you fetched from, with no trailing-slash difference.authorization_endpointandtoken_endpointpresent and reachable.code_challenge_methods_supportedcontainingS256. MCP clients use PKCE, and a client that finds no S256 support may refuse to proceed. (If PKCE is new to you, see What is PKCE.)
For the difference between an OpenID Connect provider and bare OAuth 2.0 here, and why it matters, see Is Your MCP Server Using OpenID Connect or Just OAuth 2.0.
Step 4: Can the Client Register Itself?
Hosted clients like Claude and ChatGPT connectors typically don't have a pre-provisioned client ID for your server. They rely on dynamic client registration (RFC 7591) via a registration_endpoint in the authorization server metadata, unless you give them a client ID manually.
If registration_endpoint is absent, the client has two options: use a client ID you supplied in its connector settings, or fail. Many "authorization failed" reports end here. Either add dynamic registration at the authorization server, or document the client ID and redirect URI each client must use.
Also verify the redirect URIs your authorization server allows. Each client has its own callback URL, and an allowlist containing only localhost will pass your manual tests and fail every hosted client.
After Discovery: Quick Sanity Checks
Once the four steps return clean responses and you can obtain a token, confirm the server accepts it:
- Send the token as
Authorization: Bearer <token>to the MCP endpoint and repeat theinitializecall. A200with aserverInfoobject means auth works. - Follow with
tools/list. Ifinitializesucceeds but tools are empty, the problem is scope or tool registration, not authentication. - If the token is accepted by the authorization server but rejected by the MCP server, check the
audclaim against theresourcevalue from step 2. Audience mismatches are the usual cause.
Run the Whole Chain in One Request
Doing this by hand is useful once. For regular checks, MCP Inspector runs the handshake against a URL, detects OAuth-protected servers from the WWW-Authenticate challenge and RFC 9728 metadata, and enumerates tools/list where the server allows it. It tests from outside your network with no cookies, which is exactly the position a hosted client is in, so it catches the proxy, redirect, and catch-all failures that don't show up when you test from your own machine.
Debugging Order Cheat Sheet
- Is the response a
401with aWWW-Authenticate: Bearer resource_metadata=...header? - Is
resource_metadataan absolutehttps://URL? - Does it return JSON (not HTML) with a
resourcematching your MCP URL? - Does
authorization_servers[0]serve RFC 8414 or OIDC metadata at the correct path? - Does
issuermatch exactly, and doescode_challenge_methods_supportedincludeS256? - Is there a
registration_endpoint, or a documented client ID for hosted clients? - Do the registered redirect URIs include each hosted client's callback?
- Does the token's
audmatch theresourcevalue?
Work down the list and stop at the first "no". That is your bug.
Follow Trango Compute on LinkedIn
We post updates on new tools, context engineering patterns, and LLM cost research.