
MCP Authentication Explained: Fix 401 Errors in a Remote MCP Server
Debug remote MCP authentication step by step: OAuth discovery, callback failures, token audiences, scopes, and AWS API Gateway responses.

Debug remote MCP authentication step by step: OAuth discovery, callback failures, token audiences, scopes, and AWS API Gateway responses.
An “Unauthorized” error from a remote MCP server doesn’t necessarily mean your credentials are wrong. The first 401 may be the server asking the client to start an OAuth login. If the client has already logged in and sent an access token, that same status points to a different problem.
Before rotating keys or signing in again, find the request that failed. Was it the first call to /mcp, the token exchange, or a tool calling another API? That distinction will save you a lot of guessing.
The examples here cover OAuth on remote HTTP servers, using the 2026-07-28 MCP revision. Local stdio servers handle credentials differently. Check your client and SDK versions before copying anything; older implementations may use an earlier flow. Replace the example URLs with your own. The snippets are diagnostic examples, not a tested integration for a particular desktop client.
Open the client’s network trace or the server’s access logs and look for the last successful request. Keep the request IDs so you can follow the same attempt across services. Remove tokens, authorization codes, and cookies before sharing logs.
| Where it stops | What to check first |
|---|---|
| First request returns 401; login never opens | Authentication challenge and metadata discovery |
| Metadata request returns HTML or 404 | Public routes, reverse proxy, and URL paths |
| Login page rejects the client | Client registration and redirect URI |
| Login succeeds; token exchange fails | PKCE verifier, callback configuration, and client identity |
| Token is issued; MCP still returns 401 | Token validation and whether the header reaches your application |
| Authentication passes; operation returns 403 | Required scopes and application permissions |
| Terminal works; browser client fails | Preflight response and exposed response headers |
One easy mix-up: a tool can connect successfully, call an upstream API, and return that API’s authorization error. If the failure is inside a tool result, investigate the downstream credentials first.
A protected endpoint can answer an unauthenticated request with this challenge:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp", scope="reports:read"
Look for WWW-Authenticate. A body containing only Unauthorized gives you very little to work with; this header points the client toward the login configuration.
To see what your server actually returns, save the body of a failing read-only request as mcp-request.json and replay it without credentials:
# Run in Bash. Use your server URL and the captured read-only request body.
MCP_URL='https://mcp.example.com/mcp'
curl --silent --show-error \
--dump-header - \
--output /dev/null \
--header 'Content-Type: application/json' \
--header 'Accept: application/json, text/event-stream' \
--data-binary @mcp-request.json \
"$MCP_URL"
Add the protocol-specific headers from the original request. This command supplies only the common content headers and leaves redirects visible.
Use the original request method. curl -I sends HEAD and may hit a different route. A 400 caused by a missing protocol header won’t tell you whether OAuth works.
Without resource_metadata, the client must fall back to well-known URLs. For /mcp, it tries /.well-known/oauth-protected-resource/mcp, then /.well-known/oauth-protected-resource. Check those before blaming a missing challenge parameter. Discovery requirements
Discovery happens in two hops. Your MCP server identifies the authorization server. That server publishes the endpoints used for login and token exchange.
Fetch the resource metadata URL from the challenge:
curl --silent --show-error --fail-with-body \
'https://mcp.example.com/.well-known/oauth-protected-resource/mcp' \
| python3 -m json.tool
For an MCP endpoint at /mcp, the document might contain:
{
"resource": "https://mcp.example.com/mcp",
"authorization_servers": ["https://auth.example.com"],
"scopes_supported": ["reports:read"]
}
Keep this endpoint reachable before login. Otherwise the client needs a token to discover how to get a token. The resource value must identify the resource it’s connecting to. Protected resource metadata, RFC 9728
With https://auth.example.com as the issuer, the next request goes here:
curl --silent --show-error --fail-with-body \
'https://auth.example.com/.well-known/oauth-authorization-server' \
| python3 -m json.tool
The provider may use OIDC discovery instead. Either way, inspect issuer, authorization_endpoint, and token_endpoint. The issuer must match exactly. Providers with tenant paths need the discovery URL construction described in the spec; appending a suffix to the issuer won’t always produce the right URL. Authorization server discovery
If either metadata URL returns your application’s login page, fix routing first. Look for a catch-all frontend route or authentication middleware covering the metadata endpoint.
Your account can be valid while the MCP client’s registration is wrong. Compare the request’s client ID and callback URL with the provider’s registration. Pay attention to the callback path and hostname: localhost and 127.0.0.1 aren’t interchangeable.
MCP 2026-07-28 supports pre-registration and Client ID Metadata Documents (CIMD), plus Dynamic Client Registration (DCR) for compatibility. DCR is deprecated and optional, so a missing registration_endpoint isn’t necessarily an error.
An HTTPS URL used as the client ID is a clue to check CIMD support. If the provider doesn’t support it, use a registration method both sides understand. For DCR with an OIDC provider, desktop clients may also need application_type: "native".
If you’ve changed issuers, reset the affected connection’s registration through the client’s settings. Don’t reuse the old issuer’s client credentials. MCP client registration
After the browser returns to the client, look for a successful token response. A completed callback alone doesn’t mean the exchange worked.
PKCE ties the authorization request to the code exchange. The client sends a challenge first, then the original verifier when it requests a token. Losing that verifier breaks the exchange. Generating another one at the token step won’t help. PKCE, RFC 7636
Read the provider’s error response before retrying. Check the callback URI and whether another process owns the callback port. If the client runs in a container or on a remote machine, make sure the browser can reach its callback listener.
After fixing the configuration, start a fresh login instead of replaying the captured code.
The client must include the target resource in both the authorization request and the token request. Check that value when a newly issued token still gets a 401. The MCP server must reject a token issued for another resource. MCP resource and token requirements
For JWT access tokens, work through the validator’s checks:
| Check | Question |
|---|---|
| Signature | Is the signature valid under the trusted issuer's keys and allowed algorithm? |
| Issuer | Did the configured authorization server issue this token? |
| Audience | Is this MCP resource an intended recipient? |
| Expiry | Is the access token still valid? |
| Token type | Is this an access token, rather than an ID token from the login flow? |
A decoded JWT payload is only data until you validate it. Use a maintained validator configured for the provider’s access-token profile. For opaque tokens, use the provider’s supported validation mechanism. JWT access-token validation, RFC 9068
Also confirm that Authorization reaches the application. Logging authorization_header_present: true is enough for that check. Keep the token itself out of the logs.
If a tool calls another API, handle that API’s credentials separately. Don’t forward the incoming MCP bearer token to it. MCP token-passthrough guidance
When the token is valid but lacks a required scope, the response can look like this:
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope", scope="reports:read"
Bearer authentication uses 401 for invalid or expired tokens and 403 for insufficient scope. Compare what the operation requires with what the token grants. Signing in again with the same permissions won’t resolve a scope mismatch. Bearer-token errors, RFC 6750
Your application still needs its own access checks. reports:read can permit the operation without granting access to every customer’s reports. Restrict the database query to the authenticated user’s tenant.
Test this with two tenants: a valid token for one must not read the other’s report.
For an API Gateway and Lambda deployment, start with the request path:
MCP client -> API Gateway -> authorizer, if configured -> Lambda
Add CloudFront or a WAF if your setup uses them. Match the gateway access logs to the Lambda logs. When there’s no corresponding Lambda invocation, investigate the gateway or the layer in front of it.
Keep the public discovery routes reachable. If an upstream authorizer returns a generic rejection without the MCP challenge, the client still needs working well-known metadata routes.
Browser clients add another place to look: CORS. On HTTP APIs, API Gateway can handle preflight requests itself and override the integration’s CORS headers. If your $default route requires authorization, you may need an explicit unauthenticated OPTIONS /{proxy+} route. AWS HTTP API CORS documentation
Allow the request headers the client sends, and expose the response’s challenge header:
Access-Control-Expose-Headers: WWW-Authenticate
Authorization belongs to the request; WWW-Authenticate belongs to the response. Allowing one doesn’t expose the other. Error responses need the appropriate allowed origin too. A successful curl request won’t catch this problem because curl doesn’t enforce browser CORS. Response-header exposure
For protocol-version and routing failures, see the Lambda MCP migration post. Those can interrupt the connection before OAuth gets a chance to work.
Use the client that originally failed, with its version recorded alongside your server SDK and protocol revision:
| Test | Evidence to keep |
|---|---|
| Fresh connection without a token | Client discovers the authorization server and starts login |
| Successful login | Token exchange completes and a read-only MCP operation succeeds |
| Expired or wrong-audience token | Protected request is rejected with 401 |
| Valid token lacking a required scope | Operation is denied with the expected scope challenge |
| Cross-tenant request | Application denies access even with valid authentication |
| Browser connection, if supported | Preflight passes and the client can read the challenge header |
Once those checks pass, reconnect from a fresh session and run the original read-only operation. Keep its request ID with the server logs so the next failure has a useful comparison.
I email when a new post goes up. One send a week, and only if there's something new.
Related reading
Kept below the post instead of in a sidebar, with a slow continuous motion for a cleaner editorial feel.
On this post
Comments
A reply stays under the note it answers.
No comments yet.
If you have a note on MCP Authentication Explained: Fix 401 Errors in a Remote MCP Server, sign in and leave it.