Zuplo
On this page
MCP error

OAuth completes but the call is still 401

The string your client printed
Authorization with the MCP server failed

The client says it is connected, but no usable credential reaches your server: either it doesn't attach the token, or the token carries an audience your server is right to reject.

MCP specification
2026-07-28

Is this you?

Take the token from your token endpoint's log and POST it to the URL you registered with the client, not the URL your server thinks it lives at. One response separates a credential the client never sent from a credential your server refuses. The request below is shaped for 2026-07-28; against a server on 2025-11-25 or earlier, drop the MCP-Protocol-Version and Mcp-Method headers and the _meta block, and expect it to ask for the initialize handshake that revision required. There is no Mcp-Name to drop: that header is required only on the methods that carry a name, and tools/list is not one.

TerminalRun this to check — it changes nothing
curl -sS -i -X POST 'https://your-server.example.com/mcp' \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'MCP-Protocol-Version: 2026-07-28' \
  -H 'Mcp-Method: tools/list' \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{
        "io.modelcontextprotocol/protocolVersion":"2026-07-28",
        "io.modelcontextprotocol/clientCapabilities":{}}}}'
This error
Two shapes. A 301, 302, 307, or 308 with a Location on a different host — the hop that drops your Authorization header. Or 401 with WWW-Authenticate: Bearer error="invalid_token": the token was minted for someone else. Decode it and compare aud and iss against the canonical MCP server URL and the issuer you advertise.
Then the fix is the next section.
Working
Anything that is not an auth challenge. A 200 with a JSON-RPC result is the clean case, and a JSON-RPC error about the shape of the request counts too — -32602 for a missing _meta field, -32022 for a version this server does not implement, -32020 for a header that disagrees with the body. All three mean the request got past authentication and failed later, so your validator accepted the token. If your client still fails, the credential is being lost on the way in rather than rejected on arrival: look at the redirect and at what the client attaches, not at your validator.
Then this isn't your problem — try the error reference.
6 other strings clients print for this
  • Authorization with the MCP server failed. You can check your credentials and permissions. If this persists, share this reference with support: "ofid_..."
  • UnauthorizedError: Unauthorized
  • {"error":"invalid_token","error_description":"Authentication required"}
  • Missing Authorization header
  • AADSTS9010010
  • invalid_target

Fix it

Replay an access token your authorization server issued against the exact URL you registered with the client. If that URL answers a 301, 302, 307, or 308 pointing at a different origin, the credential is being dropped on the hop — register the redirect target instead. A 401 carrying error="invalid_token" means the token did arrive: decode it, check aud and exp, and audience-restrict the token at the authorization server to the canonical MCP server URL the client sends as the RFC 8707 resource parameter, rather than loosening the aud check in your server. If the replay succeeds and only the person who added the connector can use it, nothing is broken there: each user authorizes separately.

Have your agent fix this

Copy a prompt that sends your coding agent to this page to find and apply the right fix for your project.

The cause depends on your platform. Pick yours.

Claude custom connectors

If the connector was added in claude.ai, Claude Desktop, or Cowork and reports itself connected

  1. 1 Register the URL your server actually listens on. Claude follows redirects, so a URL that answers 301, 302, 307, or 308 to a different host — apex to www., region routing, a vanity domain in front of a CDN — loses the Authorization header on the hop. Anthropic documents this as a cause of Authorization with the MCP server failed, and as the reason the same server works from Claude Code and MCP Inspector: local clients fail fast on a redirect instead of following it.
  2. 2 Issue tokens for the audience Claude asks for, and compare aud against that canonical value rather than byte-comparing what the user typed. Claude sends the RFC 8707 resource parameter on both the authorization and the token request, set to the canonical form of your MCP server URL: lowercase scheme and host, no trailing slash, no fragment, no default port, path included.
  3. 3 Match the resource field in your protected resource metadata to the canonical form of the URL users enter in Claude — the same lowercased, slash-stripped form the previous step describes — path included. If authorization_servers lists more than one issuer, Claude uses the first entry and doesn't fall back to later ones.
  4. 4 Expect each user to authorize for themselves. On Team and Enterprise plans only an Owner can add a connector, from Admin settings; every member then finds it under Settings > Connectors and clicks Connect to authenticate individually. So a connector that works only for the person who set it up is documented behavior, not a bug.
  5. 5 Claude re-runs OAuth only on a transport-level 401. A 200 carrying isError: true is handed to the model as a tool result and produces no auth prompt, which is how anonymous tool calls repeat indefinitely. Claude also refreshes reactively on a 401, so return invalid_grant — not invalid_request or a custom code — when a refresh token is dead.
JSONjson
{
  "resource": "https://mcp.example.com/mcp",
  "authorization_servers": ["https://auth.example.com"],
  "scopes_supported": ["mcp:read"],
  "bearer_methods_supported": ["header"]
}
Anthropic: troubleshooting connectors

ChatGPT connectors

If the connector was added in ChatGPT or Codex

  1. 1 ChatGPT appends resource=<the resource value from your protected resource metadata> to both the authorization and the token request. OpenAI's own documentation tells you to configure the authorization server to copy that value into the access token, commonly the aud claim. If your identity provider ignores the parameter, every token you get back carries the provider's default audience and your server is correct to reject it.
  2. 2 Put both halves in place, or ChatGPT never shows its linking UI: per-tool securitySchemes in the tool definition (oauth2 with the scopes you need, or noauth) and a runtime error result carrying _meta["mcp/www_authenticate"] with both an error and an error_description. With only one half, protected tools keep being called anonymously and no sign-in is ever offered.
  3. 3 Register the production redirect URI https://chatgpt.com/connector/oauth/{callback_id} shown on the app management page. Apps published before the change keep working on the legacy https://chatgpt.com/connector_platform_oauth_redirect.
  4. 4 Don't reach for a non-OAuth fallback: there isn't one. ChatGPT doesn't support machine-to-machine grants — client credentials, service accounts, JWT bearer assertions — and can't present a customer-supplied API key or mTLS certificate. It does present an OpenAI-managed client certificate whose SAN dnsName is mtls.prod.connectors.openai.com, if you want to identify the client at the transport layer.
  5. 5 Verify the token yourself, because none of it is done for you: check the signature and iss against the authorization server's JWKS, reject on exp and nbf, confirm aud or the resource claim names your server, then enforce scopes. On failure return 401 with a WWW-Authenticate header pointing back at your protected resource metadata.
JSONjson
{
  "jsonrpc": "2.0",
  "id": 4,
  "result": {
    "content": [{ "type": "text", "text": "Authentication required: no access token provided." }],
    "_meta": {
      "mcp/www_authenticate": [
        "Bearer resource_metadata=\"https://mcp.example.com/.well-known/oauth-protected-resource\", error=\"invalid_token\", error_description=\"You need to login to continue\""
      ]
    },
    "isError": true
  }
}
OpenAI: authenticate your users

Microsoft Entra ID

If your authorization server is Microsoft Entra ID and the token request fails with AADSTS9010010 or invalid_target

  1. 1 Read the rejection for what it is: Entra is refusing the resource value the client sent, because it matches no Application ID URI on your app registration. The default api://{client-id} is not enough here — the client sends the full MCP server URL, path included.
  2. 2 In the Microsoft Entra admin center, open the app registration that represents the protected API — not the OAuth client, if those are separate registrations. Under Expose an API, add your MCP server URL as an additional Application ID URI (identifierUris in the manifest). It must match exactly, path included, without a trailing slash.
  3. 3 If something in front of your server validates the audience for you — Azure App Service Authentication, for instance — add the API app's Application (client) ID and its api:// URI to Allowed token audiences. That is a different list from Allowed client applications.
  4. 4 If the OAuth client and the API are separate registrations, confirm the client holds an admin-consented API permission for the scope your API exposes.
Anthropic: Entra ID rejects the resource value

VS Code

If the server is configured in mcp.json and VS Code keeps re-sending a token your server rejects

  1. 1 Expect a stale cached credential rather than a bad audience. VS Code caches both the access token and the dynamically registered client_id, and when that credential goes stale — expiry, a rotated client, a restarted server — a 401 from a previously authorized server triggers no refresh, no re-registration, and no fresh discovery. VS Code re-sends the same token and the server stays in a failed state. Reported as microsoft/vscode#321834 and still open, with no fix shipped.
  2. 2 Recover by hand: run Authentication: Remove Dynamic Authentication Providers from the Command Palette, then restart the server from MCP: List Servers. That forces a fresh registration and OAuth flow.
  3. 3 For GitHub or Microsoft Entra authorization servers, check which servers hold your account under Manage Trusted MCP Servers in the Accounts menu. A token bound to the wrong account is indistinguishable from a wrong audience when all you have is your server's logs.
  4. 4 Add both http://127.0.0.1:33418 and https://vscode.dev/redirect to your authorization server's redirect URI list. VS Code tries dynamic client registration first and falls back to a client-credentials workflow if the identity provider doesn't support it.
  5. 5 Read VS Code's own account of the exchange before touching your validator: MCP: List Servers, pick the server, choose Show Output.
VS Code issue #321834 (open)

Cursor

If Cursor flips back to Needs authentication after a successful sign-in, and you are working over Remote SSH

  1. 1 Check your version first. Cursor staff attributed this to Remote SSH specifically — the callback completes on the local side while the MCP extension runs on the remote host, so the saved tokens never reach the remote extension host — and said it was fixed in v3.3. If you're past that, this branch is not your problem.
  2. 2 Match the signature from the forum report: after a successful flow the client logs Saving tokens and OAuth authorization completed, reloads, then begins a fresh flow (Saving PKCE code verifier) and ends at UnauthorizedError: Unauthorized. The server sees only discovery requests — no POST ever arrives carrying the bearer. A separate report on the same forum covers Cursor not refreshing MCP access tokens at all.
  3. 3 On Remote SSH, skip the browser flow entirely, which is the workaround staff gave: an API key in a header, from an environment variable.
  4. 4 Confirm what Cursor actually sent before changing anything server-side: Output panel, then MCP Logs in the dropdown.
  5. 5 If your provider issues a fixed client, skip dynamic registration. Add an auth object to the remote server entry with CLIENT_ID, an optional CLIENT_SECRET for confidential clients, and scopes. Omit scopes and Cursor reads scopes_supported from /.well-known/oauth-authorization-server.
  6. 6 Cursor's redirect URIs are fixed per surface: https://www.cursor.com/agents/mcp/oauth/callback for web and Cursor Agents, http://localhost:8787/callback for the desktop app. Register both if your users authenticate from both, since the server is identified through the OAuth state parameter rather than a per-server callback.
  7. 7 Don't assume the token you get back is audience-restricted to your server: Cursor documents no RFC 8707 resource behavior. Validate iss and aud, and read what arrives.
JSONjson
{
  "mcpServers": {
    "oauth-server": {
      "url": "https://mcp.example.com/mcp",
      "auth": {
        "CLIENT_ID": "${env:MCP_CLIENT_ID}",
        "CLIENT_SECRET": "${env:MCP_CLIENT_SECRET}",
        "scopes": ["mcp:read", "mcp:write"]
      }
    }
  }
}
Cursor forum: OAuth token not used after authentication

MCP SDKs (server side)

If you build the server on an official MCP SDK and want the audience check and the 401 to be right

  1. 1 Neither official SDK checks the audience for you. The Python SDK hands you a TokenVerifier whose verify_token returns an AccessToken or None; the example in the SDK docs is a stub with no verification at all. Compare the token's audience against the resource_server_url you set in AuthSettings and return None on a mismatch.
  2. 2 AccessToken.resource is the RFC 8707 resource indicator — populate it, so nothing downstream has to re-derive which resource the token was minted for.
  3. 3 The SDK's RequireAuthMiddleware then answers 401 with WWW-Authenticate: Bearer error="invalid_token", error_description="Authentication required", plus a resource_metadata parameter when you configure resource_metadata_url. Configure it: without the pointer the client has to fall back to probing well-known paths.
  4. 4 In the TypeScript SDK the same job is requireBearerAuth({ verifier, requiredScopes, resourceMetadataUrl }), and your OAuthTokenVerifier.verifyAccessToken must throw an OAuthError with OAuthErrorCode.InvalidToken for a wrong audience. Note it also rejects any token whose expiresAt is unset, so populate it from the introspection response or the JWT exp.
  5. 5 Keep the status codes straight: 401 with error="invalid_token" for a missing, expired, or wrong-audience token; 403 with error="insufficient_scope" and a scope parameter only for a scope shortfall. Never answer 200 with a tool error that says "please sign in" — clients pass that to the model as a result and no auth prompt appears.
python
from mcp.server.auth.provider import AccessToken, TokenVerifier

RESOURCE = "https://mcp.example.com/mcp"

class AudienceCheckingVerifier(TokenVerifier):
    async def verify_token(self, token: str) -> AccessToken | None:
        claims = await introspect(token)  # your IdP call, or local JWT verify
        if RESOURCE not in claims.get("aud", []):
            return None  # a token for another service is not a token for you
        return AccessToken(
            token=token,
            client_id=claims["client_id"],
            scopes=claims["scope"].split(),
            expires_at=claims["exp"],
            resource=RESOURCE,
        )
MCP Python SDK: authorization

Something else

Two questions settle this, and that curl answers both. If the credential never arrived, find the hop that rewrote the URL — a redirect to www., a region router, a vanity domain in front of a CDN — and register the host that actually serves /mcp, because an HTTP client drops Authorization when a redirect crosses origins. If it arrived and you rejected it, decode it. The MCP specification requires you to accept only tokens issued for you as the audience, so make the client send the RFC 8707 resource parameter naming your canonical MCP server URL on both the authorization and the token request, and make the authorization server audience-restrict to that value — a custom audience, an API registration, a resource identifier, whatever it calls the setting. Fix the audience where the token is minted, never by widening the aud check in your server.

Why it happens

Three different failures leave the same shape in your access log.

A cross-origin redirect on the MCP endpoint drops the credential in transit: the client attaches Authorization, follows the 3xx, and standard HTTP client behavior strips the header, so the redirect target logs an unauthenticated request.

An audience mismatch gets the token rejected on arrival: the client sends the RFC 8707 resource parameter naming your canonical MCP server URL, the authorization server ignores it and mints a token for its own default audience, and your aud check correctly refuses it.

Per-user credential scoping is not a failure at all. Each user authorizes a connector for themselves, so a server that works for whoever set it up and returns 401 to everyone else is doing what the client documents.

The exchange

  • MCP client holds a valid token
  • Redirect at the edge apex to www.
  • MCP server the redirect target
  1. MCP client to Redirect at the edge

    POST /mcp (carries the credential) Authorization: Bearer eyJ...
  2. A redirect that crosses to a different origin makes an HTTP client drop the Authorization header. The retried request is the one your logs show, and it carries no credential.

    Redirect at the edge to MCP client

    308 Permanent Redirect Location: www.example.com/mcp
  3. MCP client to MCP server

    POST /mcp (retried) no Authorization header
  4. The same 401 appears when the token does arrive but was minted for another audience. Compare the aud claim against the canonical MCP server URL before you go looking for a redirect.

    MCP server to MCP client

    401 Unauthorized (this step fails) error="invalid_token"

    Flow stops here

The MCP client posts to the URL it was given, carrying a valid bearer token. That host answers 308 to a different origin. The client retries against the redirect target, and standard HTTP behavior has dropped the Authorization header on the way, so the MCP server answers 401 invalid_token.
Zuplo

A valid token establishes who the caller is and nothing else.

Can we give some callers less than the whole route?

require-user-claims-inbound

Yes: rules over the caller's claims — groups, roles, tenant, service-account subject — decide whether the call proceeds, with a 403 when it does not.

Which tools exist for this caller in the first place?

mcp-capability-filter-inbound

Only the ones the route exposes — for that caller, nothing else exists. An unlisted capability is refused with MethodNotFound before the request is forwarded, and accessControl.mode: rolesAndGroups makes the list itself per caller.

Is this token meant for us, or for some other service?

RFC 8707 resource indicators

A gateway-issued token is bound to one route's canonical resource URI, so a token minted elsewhere is rejected here rather than accepted because it parses.

What did the agent do with it?

capability_invocation

Every tool call emits an event carrying the caller, the capability, the upstream, the outcome, and the latency. Tokens aren't logged.

All of these attach to one MCP route's policies.inbound — the same policy engine on the way in and on the way out. Your MCP server keeps the code and the authentication it has today.

Stop debugging auth on every MCP server

A gateway in front of your MCP servers handles discovery, audience binding, and token validation once — for every server and every client.