Skip to content
Chapter 8 of 8 · MCP authDiagnosing 401 and 403 responses

16 min read

Diagnosing 401 and 403 responses

On this page

Authorization failures in MCP arrive as an HTTP status and a WWW-Authenticate header on one specific call, and almost everything worth knowing is in those two fields. The status separates "this credential is not acceptable" from "this credential is acceptable and does not permit this", and the challenge names which check failed: invalid_token with a description, or insufficient_scope with the scope to ask for.

Both look identical from inside an agent, which usually surfaces them as a failed tool call, and the reflex they both provoke is to re-run the OAuth flow. That reflex repairs exactly one of the two conditions, and diagnosing which one is in play is a matter of pulling a small number of facts in an order where each eliminates a class of cause.

Pull the evidence in this order

#What to pullWhere fromWhat it rules out
1Full WWW-Authenticate on the failing response, not just the statusResponse headers of one failing tools/callEverything except the named error; invalid_token and insufficient_scope are different investigations
2Whether the failing request carried an Authorization headerRequest headers, client sideA client bug where the header is dropped on POST, on redirect, or by a proxy
3Whether the succeeding calls carried oneRequest headers of initialize and tools/listThe belief that authentication is working; unauthenticated success proves nothing
4Exact URL and path of each callRequest lineA different path with a different policy, or a redirect to a host that strips the header
5Decoded token claims: iss, aud, exp, iat, scope, client_idThe token itselfExpiry, clock skew, wrong client, wrong issuer, missing scopes
6The server's resource valueIts protected resource metadata documentYour assumption about what the audience should be
7Whether resource was sent on the authorization and token requestsClient's own outbound traceAn issuance-side omission versus a validation-side bug
8Introspection response, for opaque tokensAuthorization serverRevocation, and claims you cannot see by decoding

Item 3 is the one that ends most of these investigations, and it is the one nobody reaches for, because two successful calls feel like proof that authentication works. Many deployments serve initialize and tools/list unauthenticated, or from a path with its own policy, so those calls would have succeeded with no token, an expired token, or a token from a different tenant. Their success measures nothing about the credential.

The belief item 3 defeats is that a successful call proves the credential works, and it is tempting because that inference is sound nearly everywhere else. In an ordinary API every route sits behind the same middleware, so a 200 on any route really does establish that the token was accepted on that host. MCP servers break the assumption often, and for a defensible reason: initialize and tools/list are how a client discovers what it may do, and a deployment may prefer to answer them to anyone rather than demand a credential before a client knows what it is connecting to. Success and authentication therefore come apart, and the only way to notice is to make the same call carrying nothing. Two successes and a failure is not evidence that authentication works and authorization does not — it is not evidence about the credential in either direction until the unauthenticated call has been run.

What makes the table an ordering rather than a checklist is that each item is placed by how much it eliminates against what it costs. Item 1 is first because reading one header splits the whole space into two investigations that share nothing, and invalid_token and insufficient_scope are different diagnoses with different repairs. Items 2 and 3 follow because they are the same two commands and between them they settle whether a credential ever reached the server, which every later item quietly assumes an answer to. Items 5 to 7 cost a decode and a fetch and settle the audience question. Item 8 is last because it costs a call to somebody else's authorization server and applies only to opaque tokens. Working the list in order is not superstition: each item is cheap relative to what it rules out, and each one narrows what the next has left to explain.

Items 1 and 2 come out of a single hand-made call, with the client removed from the picture:

bash
curl -sS -i -X POST https://mcp.partner.example.com/mcp \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
       "params":{"name":"reports.list","arguments":{}}}'
http
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="audience mismatch", resource_metadata="https://mcp.partner.example.com/.well-known/oauth-protected-resource/mcp"

Item 3 is the same endpoint with no credential at all:

bash
curl -sS -o /dev/null -w '%{http_code}\n' -X POST https://mcp.partner.example.com/mcp \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
text
200

Two commands, and a whole class of theory is gone: the credential was never accepted, the earlier calls never needed one, and the challenge names the check that failed.

Flowchart: tools/call fails → HTTP status; HTTP status → error= in WWW-Authenticate (401); HTTP status → insufficient_scope: new authorization request with the named scope (403); HTTP status → Server-side or upstream; not a client credential problem (5xx / -32603); error= in WWW-Authenticate → exp in the past? (invalid_token); error= in WWW-Authenticate → No token reached the server: header dropped or endpoint unauthenticated (none / bare Bearer); exp in the past? → Refresh once, retry once (yes); exp in the past? → Compare aud with the server's published resource, byte for byte (no).

A trailing slash tells you one thing and not three

Suppose item 5 comes back with aud: "https://mcp.partner.example.com/mcp/" while the metadata document at item 6 publishes resource: "https://mcp.partner.example.com/mcp". One character, and the comparison is exact string equality, so it fails.

What that supports is a narrow conclusion: the token was minted for an identifier that is not the one this server validates against, and the mismatch is at issuance. It is enough to explain the 401 and enough to know where the change goes — the authorization server's registered audience for this resource, and the resource parameter the client sends, both set to the string the metadata publishes.

Several other conclusions do not follow. It does not follow that the fault is the partner's, since the trailing slash may have been introduced by your client constructing the identifier from a config URL rather than copying it from metadata, which is the mechanism chapter 3, on audience binding, warned about. It does not follow that the partner's server should be more forgiving, because URI equivalence for authorization decisions is a swamp — %2F versus /, default ports, case in the path, index suffixes — and every relaxation is a place where two different resources can be argued to be the same one. It also does not follow that this is the only fault. Fixing the audience may reveal a 403 behind it, since the scope check has never once run against this token; nothing in the investigation so far has confirmed the grant carries what these tools require.

What the client is supposed to do with each status

The two failures call for opposite responses, and only one of them is a retry.

401 invalid_token403 insufficient_scope
What the server is sayingThis credential is not acceptableThis credential is acceptable and does not permit this
Extra parameters on the challengeerror_description, resource_metadatascope="reports:write" — what to ask for
Correct client actionRefresh once; if it fails again, restart discovery and re-authorizeStart a new authorization request including the named scope, with user consent
Is user interaction neededUsually not — refresh is silentAlways — consent is the point
Does retrying the same call helpOnly after a successful refreshNever

A refresh sends the refresh token to the authorization server and receives a new access token covering the scopes the existing grant already carries. There is no mechanism by which it adds one, because adding a scope is a decision about what the user has permitted and the user is not present during a refresh. Collapsing both failures into 401, so that clients "just re-authenticate either way", produces a client that answers a scope problem by refreshing, receives an identical token, calls again, receives the identical 403 rendered as a 401, and loops. With three retries and exponential backoff that is four token-endpoint requests and four failed tool calls per invocation, and an agent that retries the tool at its own layer multiplies that again — a few hundred requests a minute against the token endpoint for a condition that a single consent screen would have fixed.

The arithmetic in that loop is a product rather than a sum, which is what turns a one-character misconfiguration into a rate limit. One invocation with three retries makes four attempts, and each attempt is preceded by a refresh, so a single invocation costs four token-endpoint requests. The agent then retries the tool at its own layer, and its retries sit outside the client's, so each of them re-runs the entire four-attempt sequence — nested layers multiply rather than add. Multiply once more by however many tool calls a minute the agent makes, and the token endpoint sees a rate that is the product of three numbers each of which was chosen as sensible on its own. None of the three layers contains a bug. It is the standard behaviour of independent retry policies stacked without a shared budget, and it is worth recognising because the condition being retried is one that no number of attempts can repair.

The information loss is the more serious half. A 403's challenge carries the scope parameter naming exactly what is missing, which is what lets a client construct the follow-up authorization request without a human reading documentation. Returning 401 discards that, so even a client that guesses the scope problem correctly has nothing to ask for.

Four failures that present as a credential problem and are not

The evidence table converges quickly when the problem is in the token. A handful of failures produce identical symptoms with an entirely correct token, and each has a signature in the trace.

Clock skew shows up as invalid_token on a token whose exp is comfortably in the future by your clock. Compare iat against the server's Date response header: a server running 90 seconds slow will reject a token issued 30 seconds ago as not yet valid, and a server running fast will reject one 40 seconds before your clock says it expires. The 60-second clockTolerance from chapter 4, on validating tokens in your own server, exists for this, and a virtualised host whose NTP — the daemon that keeps the machine clock synchronised with a time server — has drifted past that is a real and boring cause.

Which direction produces which symptom is easy to get backwards, so it is worth deriving rather than memorising. Validation asks two questions against the server's own clock: is now past exp, and is now before nbf. A clock reading ahead of true time reaches exp sooner than it should, so it rejects tokens as expired while your clock still shows life in them — running fast means expiring early. A clock reading behind true time has not yet arrived at the moment a freshly issued token became valid, so it rejects tokens as not yet valid — running slow means arriving before the token does. The tolerance widens both windows by the same amount, which is why one allowance covers both cases, and why drift beyond the allowance produces one symptom or the other depending only on which way the host went.

A header dropped in transit produces a challenge with no error parameter at all, because the server never saw a token to find fault with. The usual mechanisms are a redirect the client followed — curl -L and most HTTP libraries drop Authorization when a redirect crosses hosts, which is correct behaviour and surprising the first time — a corporate proxy that rewrites requests, or an API gateway configured to strip inbound authorization headers because it does its own. Item 2 in the table catches the client-side version; a server-side capture, or a request to an echo endpoint on the same host, catches the rest.

A cached response makes the failure outlive its cause. Protected resource metadata is served with Cache-Control: max-age=3600 for good reasons, so a server that published the wrong resource value and fixed it ten minutes ago is still serving the wrong one to any client, CDN or proxy that fetched it inside the window. The same applies in reverse when a CDN is misconfigured to cache 401 responses, which produces the memorable symptom of a valid token failing for one user and working for their colleague on a different edge node. Re-fetching the metadata with cache headers disabled costs one command and rules both out.

A revoked grant behind an unexpired JWT completes the set. The token verifies, all claims are right, and the server refuses because it consults a revocation list or an introspection endpoint. Nothing in the token shows this, which is why item 8 exists; introspection returning {"active": false} on a token that decodes cleanly is the confirmation, and the response is a re-authorization rather than a refresh, since the refresh token in the same grant is usually revoked alongside it.

Those four are a partition by location rather than by symptom, which is what makes the list finite instead of open-ended. The clock is one location. The transport between client and server is a second. A cache holding a stale answer is a third. The authorization server's own record of the grant is a fourth. In all four the token is not among them: it decodes cleanly, its claims are right, and reading it more carefully yields nothing. That is why each of them survives a re-run of the OAuth flow, which is the reflex the whole chapter is arguing with — a new token carries the same claims as the old one and meets the same clock, the same proxy, the same cache and the same revocation record. Four locations, four different confirmations, and not one of them reached by looking harder at the credential.

The packet that gets a same-day answer

Most of these investigations end with a message to whoever operates the other side, and the difference between a same-day answer and a two-day one is usually how much of their own reproduction they have to do. The packet that gets answered contains the failing request's timestamp with a timezone, their request id if their response carries one, the decoded token claims with the signature stripped, the exact resource your client sent on the authorization and token requests, and the two curl commands above with their outputs. Never send the token itself — a live credential in a shared ticket is the leak chapter 3 modelled, and the claims are what the conversation needs anyway.

Why that particular list, rather than a longer or a shorter one, follows from what the person at the other end has to do. Their first action is to find your requests in their logs, which needs a timestamp with a timezone and, where they issue one, their own request id — without those, they begin by searching, and searching is where a day goes. Their second action is to decide whether the failure is on their side, which the decoded claims and the resource value you sent settle between them, since those two are the only inputs to the comparison that failed. Their third is to reproduce it, which the two commands supply directly. Everything else you might attach makes them read more before doing any of the three. The token is the one item that must never be included, because it is a live credential and a shared ticket is exactly the broad-read-population system chapter 3 modelled — and it is also unnecessary, since every fact the conversation needs is in the claims.

Stating the conclusion you have already reached, and the conclusions you have ruled out, is what turns it into a five-minute fix on their side. "The aud we receive is your resource identifier plus a trailing slash; your metadata publishes it without; we send resource exactly as published, so we believe the audience registered at your authorization server carries the slash" gives their engineer one field to look at.

Where the trace runs out

An MCP client that surfaces Error: MCP error -32603: Internal error and nothing else has hidden the entire investigation, because the JSON-RPC error layer and the HTTP status layer are separate and the authorization signal lives in the HTTP layer. When a client offers a raw protocol log, use it; when it does not, mitmproxy or an HTTPS_PROXY pointed at a local recorder gets you the headers, and the headers are where every item in the evidence table lives.

There is one confusion that no amount of trace-reading resolves from the client side, and it comes from the chapters just past. A 401 returned by an MCP server may be about the token you presented, or it may be a poorly translated failure from an upstream the server called on your behalf — the Salesforce grant that expired, the internal service that refused. From outside they are indistinguishable, and a client that treats the second as the first re-runs an authorization flow that cannot possibly help. A server that keeps its own authentication status codes for its own authentication, and reports upstream failures in the tool result naming the provider, is the difference between a five-minute diagnosis and a two-day escalation.

Two statuses, then, and one question each. A 401 asks whether the credential is acceptable, and the challenge's error parameter says which check refused it: expiry, issuer, audience, or a token that never arrived. A 403 asks whether an acceptable credential permits this particular tool, and its challenge names the scope to request. Refresh answers the first and can never answer the second, which is why a client that collapses them loops against a token endpoint over a condition one consent screen would end.

What remains is to run this against something real. Point a client at a remote MCP server you did not write, watch the 401 and the discovery chain in a proxy, decode the token that comes back and read its aud, then break it on purpose: drop the resource parameter and watch the audience mismatch, ask for a tool outside your granted scopes and read the scope parameter on the 403, let a token expire mid-session and watch the refresh. Each of those takes a few minutes and leaves you with a trace you have seen before, which is the entire practical difference when the next integration fails at four in the afternoon.

MCP auth — read the current version at https://daviddominguez.dev/learn/en/mcp-auth/diagnosing-401-and-403-responses

Back to top