MCP server authentication patterns: API keys, bearer tokens, and OAuth with a hosted identity provider
The three ways to protect a remote MCP server, tested code for an API key gate and an OAuth resource server, the settings WorkOS AuthKit, Auth0, and Stytch need, and which clients can use each pattern.
On this page
A remote MCP server needs to know who is calling before it runs a tool, and the MCP specification only standardizes one way to find out: OAuth 2.1, with the server acting as a resource server. In practice there are three patterns in use, and the right one depends less on security preference than on which clients have to connect. This page compares them, gives tested code for the API key gate and the OAuth resource server, lists the settings each hosted identity provider needs, and maps which clients support which pattern.
The three patterns
| Pattern | Who issues the credential | Per-user identity | Clients that can use it |
|---|---|---|---|
API key in Authorization: Bearer or a custom header | You, once, out of band | Per key, not per user | Any client that lets you set a header: Claude Code, Cursor, VS Code, Codex, Gemini CLI, Windsurf, Zed, Copilot CLI and cloud agent. Not ChatGPT. |
| Static bearer token, such as a personal access token from your product | Your product, per user | Yes, if the token maps to a user | The same as API keys; the user pastes a token |
| OAuth 2.1 with a hosted identity provider | The identity provider, after the user signs in | Yes, with scopes | Every major client with a browser sign-in: Claude, ChatGPT, Cursor, VS Code, Codex, Gemini CLI, Zed, Copilot CLI. Not JetBrains AI Assistant or Copilot cloud agent. |
The specification makes authorization optional, and says a local stdio server should take credentials from its environment instead. For a remote server open to more than one person, OAuth is the only pattern every chat-style client can complete without the user handling a secret. API keys remain the right choice for automation, CI, and agents that run without a person present.
Pattern 1: an API key gate
An API key gate is a few lines in front of the MCP handler. This version runs on any web-standard runtime, stores only SHA-256 digests of issued keys, and accepts the key either as a bearer token or as X-API-Key, so it works with clients that only let you set Authorization and with tools, like mcptest's CLI, that send X-API-Key. It was type-checked with @modelcontextprotocol/server 2.3.1 and run on Node.js 22.
// server.ts
import { createMcpHandler, McpServer, type AuthInfo } from '@modelcontextprotocol/server';
import { z } from 'zod';
// SHA-256 hex digests of the keys you issued; the keys themselves are never stored.
const KEY_DIGESTS = new Map<string, string>([
[process.env.ACME_KEY_SHA256 ?? '', 'acme-ci'],
]);
async function sha256Hex(value: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value));
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
}
async function authenticateApiKey(request: Request): Promise<AuthInfo | Response> {
const header = request.headers.get('Authorization');
const key = header?.match(/^Bearer (.+)$/i)?.[1] ?? request.headers.get('X-API-Key');
const clientId = key ? KEY_DIGESTS.get(await sha256Hex(key)) : undefined;
if (!key || !clientId) {
return Response.json(
{ jsonrpc: '2.0', error: { code: -32001, message: 'A valid API key is required' }, id: null },
{ status: 401, headers: { 'WWW-Authenticate': 'Bearer realm="orders-mcp"' } }
);
}
return { token: key, clientId, scopes: ['orders:read'] };
}
function buildServer() {
const server = new McpServer({ name: 'orders', version: '1.0.0' });
server.registerTool(
'get-order',
{
description: 'Look up one order by its id and return its shipping status',
inputSchema: z.strictObject({
id: z.string().regex(/^ord_[a-z0-9]{1,32}$/).describe('Order id, for example ord_123'),
}),
annotations: { readOnlyHint: true },
},
async ({ id }, ctx) => ({
content: [{ type: 'text', text: `Order ${id} for ${ctx.http?.authInfo?.clientId}: shipped` }],
})
);
return server;
}
const handler = createMcpHandler(buildServer);
export default {
async fetch(request: Request): Promise<Response> {
const auth = await authenticateApiKey(request);
if (auth instanceof Response) return auth;
return handler.fetch(request, { authInfo: auth });
},
};Hashing first means the lookup never compares a raw secret byte by byte, and a leaked database contains no usable keys. Passing authInfo to handler.fetch gives tools the caller's identity as ctx.http.authInfo. Add the CORS headers from the CORS reference if browser clients will connect, and list X-API-Key in Access-Control-Allow-Headers.
What the 401 should not contain. This challenge has no resource_metadata and the server publishes no OAuth metadata, because there is no OAuth. That is deliberate, but some clients probe anyway. Copilot CLI 1.0.101, configured with a static Authorization header, answered a 401 by requesting /.well-known/oauth-protected-resource/mcp, /.well-known/oauth-protected-resource, and /mcp/.well-known/oauth-protected-resource before giving up, which is why a wrong key can look like an OAuth failure in client logs. Return 401 only when the key is missing or wrong, never for a valid key.
What mcptest reports. Without a key, the headless gate stops at authorization-required and exits with code 2. With the key in an environment variable, npm run mcptest -- --api-key-env MCPTEST_API_KEY https://mcp.example.com/mcp sends it as X-API-Key; the run scored 49 out of 55, because Security Posture is only scored when OAuth metadata exists.
Pattern 2: static bearer tokens
A personal access token is an API key bound to a user. Generate it in your product, show it once, store its digest with the user id, and look it up exactly as above; the only change is that clientId becomes the user. Give tokens an expiry and a revocation path, because nothing in MCP refreshes them.
Do not confuse this with OAuth bearer tokens. A static token is opaque to the client. An OAuth access token is obtained by the client through a flow, is short-lived, and must be validated for its audience.
Pattern 3: OAuth with a hosted identity provider
In this pattern the MCP server never issues tokens. It is an OAuth 2.1 resource server, and the hosted provider is the authorization server. The server has four jobs, all required by the 2026-07-28 specification:
- Publish Protected Resource Metadata (RFC 9728) at
/.well-known/oauth-protected-resource/mcpfor an endpoint at/mcp, naming the exactresourceURL and the provider's issuer inauthorization_servers. - Challenge with
401andWWW-Authenticate: Bearer resource_metadata="...", ideally withscope. Clients only start sign-in from a401. - Validate every token: signature against the provider's JWKS,
iss, expiry, and an audience that is this server. The specification forbids accepting tokens issued for anything else, and forbids passing a client's token through to upstream APIs. - Enforce scopes per operation, answering
403witherror="insufficient_scope"and the scopes needed.
The SDKs do most of this. Tested, complete servers: Express with requireBearerAuth and mcpAuthMetadataRouter, Hono and Cloudflare Workers with the fetch-level requireBearerAuth, FastAPI with TokenVerifier and AuthSettings, and Next.js with withMcpAuth. The core of every one is the same JWT check:
const { payload } = await jwtVerify(token, jwks, { issuer: ISSUER, audience: 'https://mcp.example.com/mcp' });Copy ISSUER exactly from the provider's discovery document; jose compares it as a string, so a missing or extra trailing slash fails every token.
Client registration: CIMD first, DCR for compatibility
Before signing in, a client needs a client ID at the provider. The 2026-07-28 specification says authorization servers and clients should support Client ID Metadata Documents, where the client ID is an HTTPS URL serving the client's metadata. Dynamic Client Registration is deprecated and kept only for backward compatibility. Clients try a pre-registered ID first, then CIMD when the provider advertises client_id_metadata_document_supported, then DCR, then ask the user.
Many clients in use today still rely on DCR, so enable both on the provider until your client mix proves otherwise.
Provider settings
| Provider | authorization_servers and issuer | JWKS | Audience | Registration |
|---|---|---|---|---|
| WorkOS AuthKit | Your AuthKit domain | https://<authkit domain>/oauth2/jwks | Register the MCP server URL as a Resource Indicator; tokens carry it in aud | CIMD is off by default; enable it under Connect → Configuration. DCR can also be enabled there for older clients |
| Auth0 | Your tenant's custom or canonical domain | https://<domain>/.well-known/jwks.json | Create an API whose identifier is the MCP server URL, and enable the Resource Parameter Compatibility Profile so the client's resource parameter selects it | Auth0 recommends manually registering clients from their CIMD; Dynamic Client Registration is a tenant setting under Settings → Advanced |
| Stytch Connected Apps | Your Stytch project domain | <project domain>/.well-known/jwks.json | Stytch's own examples check the project ID as the audience, not the MCP URL | DCR is opt-in in the Connected Apps dashboard; CIMD support was announced in October 2025 |
Two provider details change your code. Stytch's authorization endpoint is a page you host with Stytch's components, so the sign-in screen lives in your app. And with Stytch's project-ID audience, all MCP servers in one project accept each other's tokens; use one project per server, or add a check of a custom claim, if that matters.
OAuth versus the clients that cannot do it
JetBrains AI Assistant does not implement OAuth for MCP servers; the request is open as LLM-25012. GitHub's Copilot cloud agent explicitly does not support remote servers that use OAuth. For those, either accept a second credential type, such as a personal access token next to OAuth, or point users at a local bridge such as mcp-remote. Accepting both is common: try the JWT check, fall back to a token lookup, and return the OAuth challenge only when both fail.
What each client supports
| Client | Version | Streamable HTTP | Legacy SSE | stdio | Protocol versions | OAuth registration | Other credentials | Private hosts | Configuration | Verified |
|---|---|---|---|---|---|---|---|---|---|---|
| Claude.ai custom connectors (opens in a new tab) (guide) | unknown | Yes | Yes | No | unknown | DCR, CIMD, pre-registered client | no auth; custom headers, bearer token, API key header limited | No | UI: Customize > Connectors > Add custom connector; UI: Team/Enterprise: Organization settings > Connectors > Add > Custom > Web | 2026-10-20 |
| Claude Code (opens in a new tab) (guide) | 2.1.304 | Yes | Yes | Yes | unknown | DCR, CIMD, pre-registered client | custom headers, bearer token, API key header, no auth | unknown | CLI: claude mcp add --transport http <name> <url>; File: .mcp.json (project) or ~/.claude.json (local and user scopes) | 2026-10-20 |
| ChatGPT custom MCP servers (developer mode, deep research) (opens in a new tab) (guide) | unknown | Yes | Yes | Bridge required | unknown | DCR, CIMD, pre-registered client | no auth; custom headers unknown | Bridge required | UI: ChatGPT web: Plugins > Add custom MCP server, then configure authentication and accept the risk warning | 2026-10-20 |
| Codex CLI (opens in a new tab) (guide) | rust-v0.163.0 | Yes | Bridge required | Yes | unknown | DCR, CIMD, pre-registered client | custom headers, bearer token, API key header; no auth unknown | unknown | CLI: codex mcp add <name> --url <url>, then codex mcp login <name>; File: ~/.codex/config.toml, [mcp_servers.<name>] | 2026-10-20 |
| Cursor (opens in a new tab) (guide) | 3.24 | Yes | Yes | Yes | unknown | DCR, pre-registered client; CIMD unknown | custom headers, bearer token, API key header, no auth | unknown | File: .cursor/mcp.json (project) or ~/.cursor/mcp.json (global), mcpServers; UI: Customize > MCPs | 2026-10-20 |
| VS Code with GitHub Copilot (opens in a new tab) (guide) | 1.142 | Yes | Yes | Yes | unknown | DCR, CIMD, pre-registered client | custom headers, bearer token, API key header, no auth | Yes | UI: MCP: Add Server command; File: .vscode/mcp.json or user mcp.json (servers), or .mcp.json and ~/.copilot/mcp-config.json (mcpServers); CLI: code --add-mcp '<json>' | 2026-10-20 |
| Windsurf (Devin Desktop, Cascade) (opens in a new tab) (guide) | unknown | Yes | Yes | Yes | unknown | DCR, CIMD, pre-registered client unknown | custom headers, bearer token, API key header; no auth unknown | unknown | File: ~/.config/devin/mcp_config.json (%APPDATA%\devin\mcp_config.json on Windows), serverUrl or url; UI: Cascade panel > ... > MCPs > Open MCP config file | 2026-10-20 |
| Gemini CLI (opens in a new tab) (guide) | unknown | Yes | Yes | Yes | unknown | DCR, pre-registered client; CIMD unknown | custom headers, bearer token, API key header, no auth | Yes | CLI: gemini mcp add --transport http <name> <url>; File: ~/.gemini/settings.json (user) or .gemini/settings.json (project), mcpServers | 2026-10-20 |
"Limited" means beta, plan-gated or restricted to some accounts. "unknown" means the vendor's current documentation does not state it. Every cell, with its source page and verification date, is in clientCompatibility.json (opens in a new tab).
Four clients are not in that dataset yet. Their current documentation says:
| Client | Custom headers | OAuth | Guide |
|---|---|---|---|
| Zed | headers in context_servers | Yes, since v0.230.0; registers with CIMD by default, DCR supported, pre-registered client ID since v1.4.2 | Zed |
| JetBrains AI Assistant | Not documented | No (LLM-25012) | JetBrains AI Assistant |
| GitHub Copilot CLI | headers, with ${VAR} expanded from the shell | Yes, with an optional pre-registered oauthClientId | Copilot CLI and cloud agent |
| GitHub Copilot cloud agent | headers, with COPILOT_MCP_ secrets | No | Copilot CLI and cloud agent |
Choosing
- A public server for chat clients: OAuth with a hosted provider. ChatGPT and claude.ai cannot send your API key in practice.
- An internal server used from IDEs and CI: API keys or personal access tokens, issued per person or per pipeline.
- Both audiences: OAuth plus personal access tokens on the same endpoint, with the OAuth challenge as the
401response.
Whatever you choose, test the failure paths as well as the happy path: no credential, a wrong one, a token for another audience, and a token without the needed scope.
Check your auth in the playground
Paste your endpoint into the MCP Playground and connect. The OAuth flight recorder shows each discovery request, the registration method the provider accepted, and the token exchange; for an API key server, add the key as a header and confirm the tool list loads. When it passes, gate deploys on it with --bearer-token-env or --api-key-env as described in How to test an MCP server before you deploy it.