Authenticating a Remote MCP Server with API Keys
Published July 9, 2026
A remote MCP server has two kinds of caller. Developer clients can set a header. Claude.ai, Claude Desktop, and Claude mobile will not paste a personal API key into a shared workspace, and they should not. We support both, on the same tools, with the same plan check.
The API key path is what Claude Code and Cursor use today. The OAuth path is what those Claude apps use when the server's OAuth flag is on. Neither path grants MCP access to a Free, Basic, Core, Plus, or Custom key. The account has to be on the MCP plan. That rule is the product, not a default of the protocol.
API keys stay out of the tool arguments
If search_foods took an api_key argument, the model would see the key, repeat it, and sometimes send it to the wrong tool. The key is a transport header: X-API-Key.
The HTTP layer reads the header once and stores it in a context variable for that request. The tool calls resolve_mcp_caller(), which reads the context. The model never receives the key as a parameter. The same context carries X-API-Usage-Type. On this plan, commercial is rejected before a rate-limit token is spent. The MCP plan is personal use. Commercial use belongs on a REST plan. That split is on the MCP page.
A missing or unknown key is an authentication error. A key for another plan is a plan error: the key is real, the subscription is not this product. Those are different messages on purpose. "Invalid key" sends someone to rotate a credential. "Not on an MCP plan" sends them to MCP pricing.
The lookup that loads the plan does not select the account email. Logs for a failed lookup record the failure type, not the key and not the address.
What a 200 with an error body does not do
Some MCP clients treat HTTP 200 as "the server is up" and then read a JSON-RPC error. That is fine for a bad tool argument. It is not fine for "you are not signed in." Claude's connector discovers OAuth from a 401 response whose WWW-Authenticate header contains resource_metadata pointing at the protected-resource document. A 200 with a polite error in the body is ignored for discovery. That is the usual reason a connector says it could not reach a server that is, in fact, reachable.
When MCP_OAUTH_ENABLED is on, a request to /mcp with neither X-API-Key nor Authorization: Bearer is that 401. A request that already has X-API-Key is the API-key path, including when the OAuth flag is on. The flag adds a sign-in challenge. It does not remove the header.
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="calorie-api", resource_metadata="https://<api-origin>/.well-known/oauth-protected-resource"
The metadata URL has to be absolute, and the resource inside the document has to be the MCP URL the user typed, path included. authorization_servers has one entry: the API origin. Clients use the first entry and do not fall through to a second.
OAuth 2.1, the short version
The API is its own authorization server. It already has accounts. The new pieces are discovery, a consent screen, and a token endpoint.
Discovery:
GET /.well-known/oauth-protected-resourceand the same document at/.well-known/oauth-protected-resource/mcpGET /.well-known/oauth-authorization-server
The authorization-server document advertises code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported including none, and client_id_metadata_document_supported: true. Those two flags together are what make client-id metadata documents work. Without both, a client falls back to dynamic registration and creates a new client row on every connection.
grant_types_supported is authorization_code and refresh_token. client_credentials is not in the list. A machine token with no user consent is the wrong shape for a personal nutrition account. The token endpoint returns unsupported_grant_type if a client asks for it anyway.
The token endpoint accepts application/x-www-form-urlencoded only. A JSON body is 415. Registration, which is a different endpoint, accepts JSON. Mixing those parsers is how a working discovery document still fails at the token step.
PKCE, codes, and refresh tokens
Authorization codes require PKCE S256. There is no plain method. The code is stored as a hash, expires in 10 minutes, and an update marks it used only when it is unused and unexpired. A second redeem is invalid_grant. The verifier has to match the challenge that was stored with the code. A mismatch burns the code.
Redirect URIs come from the client metadata or from dynamic registration. https://claude.ai/api/mcp/auth_callback works when that exact URI is in the client's list. Loopback URIs for local clients match http://localhost/callback and http://127.0.0.1/callback with any port, which is the RFC 8252 rule, and they do not match a different host. Private and non-HTTPS redirect targets are rejected. The consent screen shows the host the browser will return to, and the page will not navigate to a different host.
Refresh tokens are opaque, stored as hashes, and rotated. The old token is marked rotated in the same step that would accept it. Presenting a rotated token revokes the family and returns invalid_grant, not a custom error string. Refresh exists only when the granted scope includes offline_access. Access tokens are JWTs of about an hour, with token_use set so they cannot be used as a login session on the website. The website login check rejects that claim.
Scopes are nutrition:read, nutrition:vision, and offline_access. Read is required. Vision is required only for analyze_food_photo when the caller is an OAuth token. An API key does not carry scopes; the plan gate still decides whether photos are included.
The consent screen is a logged-in page on the site. Approving it requires the site's login token, not the MCP access token, and the account must already have MCP access. Approving a free account does not mint a code that will fail on every tool call. The user is sent to pricing instead.
Issuer URL
PUBLIC_API_ORIGIN is the origin inside the metadata: no path, HTTPS in production. It has to be the origin users put in the client. If the document says one host and the MCP URL is another, the resource check fails and the connector will not finish. Behind a proxy, set the variable. Do not trust an arbitrary Host header to name the issuer when the variable is set.
Dynamic registration is rate limited and stores a hash of the client address, not the raw address, so a leaked table is not a list of IPs. Client metadata URLs are fetched over HTTPS only, with redirects disabled, a size cap, and a rejection if the host resolves to a private, loopback, or link-local address. That fetch is a server-side request. Treat it like any other URL the client chooses.
What stays true for API keys
Claude Code:
claude mcp add --transport http calorie-api https://<api-origin>/mcp --header "X-API-Key: YOUR_KEY"
Cursor uses the same URL and the same header in mcp.json. The key is shown in full once, on the dashboard, when it is created. It is not emailed. Replace YOUR_KEY before you run the command. The command above is the contract the server accepts. It is not a transcript of a specific desktop session.
The server build that this auth sits on is the remote-server article in this series. The client-by-client steps are the connect article, published later in the same series, and the living copy is the MCP docs.
Consent, hashes, and the token response
The browser steps are ordinary, and each one has a failure that looks like a network error if you skip it.
- The client hits
/mcpwith no credential and receives 401 plus the metadata URL. - It reads
authorization_servers[0]and fetches/.well-known/oauth-authorization-serveron that origin. - It sends the user to
/oauth/authorizewithresponse_type=code, a PKCE S256 challenge, a redirect URI, and a resource that equals the MCP URL. - The API stores that request for 10 minutes and redirects the browser to
/oauth/consenton the website. - The person signs in with the website account, sees the client name, the return host, and the scopes, and approves.
- The API returns a redirect that includes a one-time code. The client posts that code and the verifier to
/oauth/tokenas a form, not as JSON.
The token response is access_token, token_type of Bearer, expires_in, scope, and refresh_token only when offline_access was granted. The access token is a JWT. The refresh token is an opaque string. We store a SHA-256 hash of the code and of the refresh token. A database read does not reveal a token a client can replay. Logs for this flow do not include the code, the refresh token, the access token, or the account email.
Client metadata documents are preferred over dynamic registration because registration inserts a client row. A directory listing that registers on every connection fills that table. Registration still exists, for clients that do not publish a metadata URL, and it is limited per address hash. It does not issue a client secret. Public clients use none.
Refresh rotation is the other half of "a leaked token should die." The update that accepts a refresh token sets rotated_at only when it is still unused, unrevoked, and unexpired. Presenting it again revokes every token in that family and returns invalid_grant. An expired refresh token is also invalid_grant. A custom error code here is how a client silently stops refreshing.
Related
Frequently Asked Questions
Why is a JSON-RPC auth error not enough for Claude.ai?
The connector looks for HTTP 401 and a WWW-Authenticate header that points at protected-resource metadata. A 200 response with an error body does not start sign-in.
Does OAuth replace API keys?
No. Claude Code and Cursor keep sending X-API-Key. Claude.ai, Claude Desktop, and Claude mobile use the sign-in screen when OAuth is enabled.
Can a client use client_credentials?
No. Every connection requires a user consent. The token endpoint rejects client_credentials.
Which plan can call the tools?
Only the MCP plan. A key or login on Free, Basic, Core, Plus, or Custom is refused, even if the OAuth screen is completed.
