MCP OAuth is the authorization flow the Model Context Protocol (MCP) specifies for HTTP servers, in which your MCP server is an OAuth 2.1 resource server that points clients to an authorization server and accepts only tokens issued for its own URL. On the current revision, 2026-07-28, clients such as ChatGPT and Claude identify themselves with Client ID Metadata Documents (CIMD), where the client_id is an HTTPS URL serving a JSON description of the client, and Dynamic Client Registration (DCR) is deprecated. Your server answers a tokenless call with a 401 naming its metadata, and your authorization server fetches CIMDs safely, enforces S256 PKCE, returns iss and binds each token to the resource the client names. This is the build-and-test companion to our MCP server security checklist, which holds the threat model.
The MCP authorization flow, from the first 401 to a token
The current revision, 2026-07-28, splits MCP authorization across Authorization, Authorization Server Discovery, Client Registration and Security Considerations, with a Security Best Practices tutorial. Your MCP server is the OAuth 2.1 resource server, the host is the client, and the authorization server signs users in and issues tokens:
- The client calls without a token. You answer
401withWWW-Authenticate: Bearer resource_metadata="…"and, per the scope selection strategy, ascopeparameter. - It fetches your MCP protected resource metadata (RFC 9728) and discards it unless
resourceis identical to the URL it called. MCP requires at least oneauthorization_serversentry. - It fetches authorization server metadata (RFC 8414 or OpenID Connect Discovery, in the discovery page’s order) and rejects it unless
issueris identical to the issuer it started from. - It stops if
code_challenge_methods_supportedis absent, then registers. It uses pre-registered credentials, else CIMD ifclient_id_metadata_document_supportedis true, else DCR throughregistration_endpoint, else asks the user. - It records the expected issuer with its PKCE verifier and opens
authorization_endpointwithcode_challenge,code_challenge_method=S256,resource,scopeandstate. - Your authorization server signs the user in, fetches and checks the CIMD, asks for consent and redirects with
code,stateandiss. - The client checks
issand redeems the code withcode_verifierand the sameresource. - Each MCP request then carries
Authorization: Bearer <token>. This revision has no sessions, and our stateless migration guide covers that change.
Minimal documents for https://mcp.example.com/mcp, resource metadata first:
{
"resource": "https://mcp.example.com/mcp",
"authorization_servers": ["https://auth.example.com"],
"scopes_supported": ["invoices:read"],
"bearer_methods_supported": ["header"]
}
{
"issuer": "https://auth.example.com",
"authorization_endpoint": "https://auth.example.com/authorize",
"token_endpoint": "https://auth.example.com/token",
"response_types_supported": ["code"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none", "private_key_jwt"],
"client_id_metadata_document_supported": true,
"authorization_response_iss_parameter_supported": true,
"scopes_supported": ["invoices:read", "invoices:write", "offline_access"]
}
offline_access belongs only in the second, because the spec says resource metadata SHOULD NOT list it.
Client ID Metadata Documents (MCP CIMD)
What the document contains
A CIMD is a JSON document whose URL is the client’s client_id. Client Registration requires an https URL with a path, at least client_id, client_name and redirect_uris, and a client_id that matches the URL exactly. The spec cites IETF draft -00 (8 October 2025, now at -02 of 6 July 2026), which also bars fragments, dot segments and credentials in the URL, plus client_secret and secret-based token_endpoint_auth_method values. A confidential client publishes jwks_uri and uses private_key_jwt. Claude Code’s document describes a public client, with the redirect URIs http://localhost/callback and http://127.0.0.1/callback and a token_endpoint_auth_method of none.
How your authorization server fetches and checks it
- Fetch a URL-shaped
client_id, then require valid JSON with the required fields and aclient_idequal to the URL. - Match
redirect_uriexactly againstredirect_uris. OAuth 2.1 lets loopback URIs differ only in port. Anthropic asks for the same onlocalhost, because Claude Code’s port changes each session. - Cache by HTTP cache headers, never cache errors, and abort if the fetch fails.
- Treat the fetch as hostile. Block private and loopback addresses and read at most 5 kilobytes, the draft’s recommendation. Draft -02 also requires a
200and forbids following redirects automatically. - On the consent screen, show the
client_idhost and the redirect URI host, and warn when every redirect URI is localhost. Anthropic’s CIMD guidance says to name the host, not the self-assertedclient_name.
CIMD trust policies
The Security Best Practices page offers domain allowlists for protected servers, any HTTPS client_id for open ones, reputation checks, and limits on domain age or certificates. None stops localhost impersonation, because CIMDs “cannot prevent localhost URL impersonation by themselves” (Security Considerations). A local program can present Claude Code’s client_id, listen on a loopback port and receive the code under Claude Code’s name. The spec’s answer is the consent screen, which MUST show the redirect URI hostname and SHOULD warn on localhost-only clients.
MCP dynamic client registration (deprecated, sometimes still needed)
DCR (RFC 7591) is deprecated, and the first revision released on or after 28 July 2027 may remove it (deprecation registry). The maintainers’ August 2025 post gives the reasons. Each client instance usually gets its own ID, so registrations pile up, and they aren’t portable. A client can’t be told its ID is invalid, and “an unauthenticated /register endpoint writes to a database”. Anthropic’s DCR notes say that under DCR, Claude registers a new client on every fresh connection.
Keep DCR or pre-registration while your authorization server can’t fetch CIMDs, while you serve clients built to revision 2025-06-18, which had no CIMD, and until your metadata advertises both values Claude needs for CIMD (see the table). The security checklist’s registration and confused deputy rows apply meanwhile.
Token audience, PKCE and the iss check
Audience. Clients MUST send resource, your canonical URI, in both the authorization and token requests, “regardless of whether authorization servers support it” (Authorization). The canonical form has a lowercase scheme and host, no fragment and, preferably, no trailing slash. Your authorization server should bind the token to it, as aud in a JWT, and may refuse unknown values with invalid_target (RFC 8707). Your MCP server MUST check the audience. Anthropic’s troubleshooting page says Claude sends the canonical form with its path, and Microsoft Entra ID then fails with AADSTS9010010 until the MCP URL is an Application ID URI.
PKCE. RFC 7636 sets the verifier at 43 to 128 unreserved characters and the S256 challenge at its base64url-encoded SHA-256. A request with no code_challenge_method means plain, so never default to S256. A wrong verifier gets invalid_grant. OAuth 2.1 makes the authorization server reject public clients that omit code_challenge, and CIMD clients using none are public. MCP clients MUST refuse to start when code_challenge_methods_supported is absent (Authorization Code Protection), and OpenAI calls a server without S256 there unsupported.
The iss check. Authorization servers SHOULD return iss on every authorization response, errors included, and MUST then set authorization_response_iss_parameter_supported: true (Authorization Response Validation). Clients compare it with the recorded issuer as exact strings, without normalisation, which stops the mix-up attack described in RFC 9207. The spec expects a later revision to make sending iss a MUST, and ChatGPT keeps its stable client ID and redirect URI for servers that meet its issuer identification rules.
MCP scope selection and step-up challenges
Put scope in the first 401. Without it, clients request everything in scopes_supported, and Anthropic says Claude also adds offline_access when your authorization server lists it. For a missing scope, answer 403 with error="insufficient_scope", resource_metadata and a scope naming everything the operation needs, in one challenge (Scope Challenge Handling).
The spec makes merging earlier and new scopes the client’s job, but Anthropic’s step-up guidance says scopes from an earlier step-up “aren’t reliably carried forward” in Claude, so list everything the user still needs. Claude treats any other 403 as final. For tool-level sign-in, ChatGPT reads securitySchemes on the tool and _meta["mcp/www_authenticate"] on an isError result instead.
What ChatGPT and Claude do today
As OpenAI’s plugin authentication page and Anthropic’s connector pages read on 19 October 2026:
| What | ChatGPT | Claude |
|---|---|---|
| Registration | CIMD first, unless the builder picks DCR, which registers once per MCP server connection | CIMD only with client_id_metadata_document_supported: true and none among auth methods, otherwise DCR |
CIMD client_id |
https://chatgpt.com/oauth/client.json, or https://chatgpt.com/oauth/{callback_id}/client.json without issuer identification |
Claude Code: https://claude.ai/oauth/claude-code-client-metadata |
| Redirect URI | https://chatgpt.com/connector_platform_oauth_redirect, or https://chatgpt.com/connector/oauth/{callback_id} without issuer identification |
https://claude.ai/api/mcp/auth_callback, or loopback on any port for Claude Code |
| Token endpoint auth | none or private_key_jwt |
none |
resource and PKCE |
Both requests, with S256 required |
Both requests, with S256 on every request |
iss |
Rejected if missing or wrong when advertised | Not mentioned |
Claude also needs an HTTP 401 to start sign-in, uses only the first authorization_servers entry, caches discovery for about 5 minutes and waits 10 seconds for discovery, registration and token responses (30 for refresh). Anthropic’s pages cite revision 2025-11-25.
MCP OAuth conformance tests
Run these against staging with test users. list_invoices and create_invoice stand for a read tool and a write tool. OTHER_RESOURCE_TOKEN is a valid token issued for another resource, and READ_ONLY_TOKEN holds read scopes only. Host this test CIMD on a domain you control, with the MCP Inspector CLI’s default callback as its redirect URI:
{
"client_id": "https://ci.example.com/oauth/test-client.json",
"client_name": "Staging OAuth conformance test",
"redirect_uris": ["http://127.0.0.1:6276/oauth/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
}
The commands pin MCP Inspector 2.11.0 (npm, 14 October 2026) with --protocol-era modern, because its default protocol era is legacy. The Inspector needs Node 22.19.0 or newer. We checked the CHALLENGE line against RFC 7636’s Appendix B example and the curl helper against a local mock. Our guide to testing an MCP server covers the tests beyond OAuth.
MCP_URL=https://mcp.staging.example.com/mcp
PRM_URL=https://mcp.staging.example.com/.well-known/oauth-protected-resource/mcp
AS_META=https://auth.staging.example.com/.well-known/oauth-authorization-server # then the issuer's path, if any
CIMD_URL=https://ci.example.com/oauth/test-client.json
H=(-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream'
-H 'MCP-Protocol-Version: 2026-07-28' -H 'Mcp-Method: tools/call')
META='"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}'
call() { # usage: call tool_name args_json [curl options]
tool=$1 args=$2; shift 2
curl -si "$MCP_URL" "${H[@]}" -H "Mcp-Name: $tool" "$@" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"'"$tool"'","arguments":'"$args"','"$META"'}}' \
| grep -iE '^(HTTP/|www-authenticate:)'
}
# Tests 1 and 2
call list_invoices '{}'
call list_invoices '{}' -H 'Authorization: Bearer not-a-token'
call list_invoices '{}' -H "Authorization: Bearer $OTHER_RESOURCE_TOKEN"
# Test 3
call create_invoice '{"customer_id":"t1"}' -H "Authorization: Bearer $READ_ONLY_TOKEN"
# Test 4
curl -s "$PRM_URL" | jq '{resource, authorization_servers, scopes_supported}'
curl -s "$AS_META" | jq '{issuer, code_challenge_methods_supported, client_id_metadata_document_supported,
authorization_response_iss_parameter_supported, token_endpoint_auth_methods_supported}'
# Test 5 (opens a browser)
mi() { npx -y @modelcontextprotocol/inspector@2.11.0 --cli "$MCP_URL" --transport http \
--protocol-era modern --client-metadata-url "$CIMD_URL" --method tools/call "$@"; echo "exit $?"; }
mi --tool-name list_invoices --tool-args-json '{}'
mi --tool-name create_invoice --tool-args-json '{"customer_id":"t1"}'
# Test 6
VERIFIER=$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=')
CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -sha256 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=')
echo "$(curl -s "$AS_META" | jq -r .authorization_endpoint)?response_type=code&client_id=https%3A%2F%2Fci.example.com%2Foauth%2Ftest-client.json&redirect_uri=http%3A%2F%2F127.0.0.1%3A6276%2Foauth%2Fcallback&code_challenge=$CHALLENGE&code_challenge_method=S256&resource=https%3A%2F%2Fmcp.staging.example.com%2Fmcp&scope=invoices%3Aread&state=t1"
# Test 7: wrong verifier
curl -s "$(curl -s "$AS_META" | jq -r .token_endpoint)" --data-urlencode grant_type=authorization_code \
--data-urlencode "code=$CODE" --data-urlencode "code_verifier=$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=')" \
--data-urlencode "client_id=$CIMD_URL" --data-urlencode redirect_uri=http://127.0.0.1:6276/oauth/callback \
--data-urlencode "resource=$MCP_URL"
Open Test 6’s URL in a browser signed in as a test user, changing one parameter per row, and read code, error and iss from the redirected address. Test 7 uses that code.
| # | Requirement | Test | Expected |
|---|---|---|---|
| 1 | Tokenless call challenged (discovery) | 1 | 401, with WWW-Authenticate: Bearer carrying resource_metadata and scope |
| 2 | Resource metadata valid (RFC 9728 §3.3) | 4 | resource identical to $MCP_URL, an authorization_servers entry and no offline_access |
| 3 | Authorization server metadata usable | 4 | issuer identical to that entry, S256, both booleans true and none among auth methods |
| 4 | Only your tokens (Token Handling) | 2 | 401 twice, error="invalid_token" (RFC 6750), no tool output |
| 5 | One complete scope challenge | 3 | 403, error="insufficient_scope" and a scope that lists every scope the call needs |
| 6 | CIMD flow (Client Registration) | 5, first call | Consent names ci.example.com and 127.0.0.1, then exit 0 |
| 7 | Step-up (Step-Up Authorization Flow) | 5, second call | Proceed with step-up authorization? [y/N], then exit 0 after y |
| 8 | client_id matches its URL |
6, client_id set to a copy saved under another path |
An error such as invalid_client, and no consent page |
| 9 | Exact redirect matching (Open Redirection) | 6, redirect_uri=https%3A%2F%2Fattacker.example%2Fcb |
An error page and no redirect |
| 10 | Safe CIMD fetching (draft -02, sections 5, 8.6, 8.7) | 6, client_id URLs that redirect, exceed 5 KB or resolve to private addresses |
Each refused before consent |
| 11 | PKCE enforced | 6 without code_challenge, then with code_challenge_method=plain |
error=invalid_request and no code (refusing plain is our policy) |
| 12 | Verifier checked (RFC 7636 §4.6) | 7 | invalid_grant |
| 13 | iss on every response |
6, approved once and denied once | iss in both redirects, equal to issuer once decoded |
| 14 | Unknown resource refused (RFC 8707 §2) | 6, resource=https%3A%2F%2Funknown.example%2Fmcp |
invalid_target |
Common mistakes
- A
resourcethat differs from the URL users paste, by a trailing slash or a missing/mcp, which makes clients discard the document. - An
issuerthat differs from theauthorization_serversentry, even by a slash, which makes clients reject the metadata. - Sign-in signalled by a tool error inside a 200, which Claude never treats as sign-in.
- A redirect from the MCP URL to another host, which strips
Authorizationin Claude. - An unchecked audience.
- Token passthrough, the confused deputy and SSRF, each covered in its own section of the security checklist.
Our view
This is our opinion. We’d make CIMD the default now and keep DCR behind a switch for named clients, with an off date before 28 July 2027, the earliest a revision can drop it. An anonymous write endpoint and an ever-growing client table are costs to carry only while needed. On a B2B server we’d allowlist the client domains we had tested, because users there act on their employer’s data. Whatever the policy, the consent screen is the real defence against localhost impersonation, so test what it shows. We’d send iss today, since it costs one parameter and ChatGPT rewards it, and list every needed scope in each 403 until Claude merges scopes reliably.
What these tests don’t prove
- A passing row shows a control held for the inputs you tried, not that no other flaw exists.
- Client duties, such as checking
issor blocking SSRF during discovery, can’t be tested from your side. - MCP Inspector stands in for real hosts, and Anthropic’s testing page says Claude has no separate staging environment.
- The ChatGPT and Claude rows restate their documentation of 19 October 2026. We didn’t connect either host to a server.
- CIMD is still an IETF draft (-02 expires on 7 January 2027), and MCP cites -00.
If you’d rather we did this
Our MCP security audit runs these tests and the full security checklist against your server in ten working days, for USD 10,000, and ends with a written verdict.
Sources
All pages opened on 19 October 2026. MCP pages belong to revision 2026-07-28 (released 28 July 2026) unless stated.
- MCP specification: Authorization, Authorization Server Discovery, Client Registration, Authorization Security Considerations, Streamable HTTP, changelog, deprecated features registry, Versioning.
- MCP: Security Best Practices, Authorization, revision 2025-11-25 and Authorization, revision 2025-06-18.
- MCP blog: David Soria Parra and Den Delimarsky, The 2026-07-28 Specification, 28 July 2026.
- MCP blog: Paul Carleton, Evolving OAuth Client Registration in the Model Context Protocol, 22 August 2025.
- MCP Inspector: overview, CLI, authorization, configuration and flags, protocol eras, plus the npm registry record (version 2.11.0).
- IETF: Aaron Parecki and Emelia Smith, OAuth Client ID Metadata Document, draft -00 (8 October 2025), draft -02 (6 July 2026) and its datatracker record.
- IETF: The OAuth 2.1 Authorization Framework, draft 13 (28 May 2025).
- RFCs: 9728, Protected Resource Metadata (April 2025), 8414, Authorization Server Metadata (June 2018), 8707, Resource Indicators (February 2020), 7636, PKCE (September 2015), 9207, Issuer Identification (March 2022) and 6750, Bearer Token Usage (October 2012).
- OpenAI, Authentication for plugin MCP servers (undated).
- Anthropic: Authentication for connectors, Lazy authentication for MCP servers, Test your connector and Troubleshoot your connector (all undated), plus Claude Code’s Client ID Metadata Document.
- Tool manuals: the curl man page and curl’s option sources for
--show-headers,--silentand--data-urlencode, the jq 1.8 manual, and the OpenSSL 3.0 manual pages fordgst,encandrand.