MCP 2026-07-28 one lesson per page
23 lessons
ShiftingSecurity & Governance · 24%Track 2 · 13 / 14

OAuth: metadata documents, issuer checks, bound credentials

Clients move from registering dynamically to identifying themselves by a URL. Several MUSTs close known OAuth attacks.

Listen to this lessonAudio overview in Gemini Notebook · about 15–25 min · opens in a new tab

Ten thousand authorization servers

You ship an MCP client used by 10,000 companies, each with its own identity provider.

With Dynamic Client Registration, that's 10,000 registrations to create, store per issuer, rotate and clean up. With a Client ID Metadata Document, it's one URL you host: https://yourapp.com/oauth/client.json. Every authorization server fetches it, and you update your redirect URIs in one place.

The attack this closes: a user connects your client to a legitimate server and to a malicious one. The malicious authorization server tries to make your client send it a code meant for the legitimate one, a mix-up attack. Your client compares iss with the issuer it started with, sees a mismatch, and refuses to redeem the code.

  • Client ID Metadata Documents (CIMD) replace Dynamic Client Registration (DCR) as the preferred method when the client isn't pre-registered. The order is: pre-registration, then CIMD (if the authorization server advertises client_id_metadata_document_supported), then DCR, then asking the user. The client's client_id is an HTTPS URL; the authorization server fetches it to learn the client's name and redirect URIs. DCR remains for authorization servers that don't support CIMD.
  • Issuer in the response (RFC 9207). Authorization servers SHOULD add iss to the authorization response. If it's present, the client MUST check it against the issuer it recorded (simple string comparison), before exchanging the code for a token. If the authorization server advertises authorization_response_iss_parameter_supported and iss is missing, the client MUST reject the response.
  • Pre-registered and DCR credentials are bound to their issuer. Clients MUST store them keyed by issuer, MUST NOT reuse them with another authorization server, and MUST register again if the authorization server changes. A CIMD client_id is portable across authorization servers; it needs no re-registration.
  • application_type must be set correctly in DCR, to avoid OpenID Connect redirect-URI conflicts.

Unchanged, and still heavily tested: tokens must be audience-bound to your server; never pass a client's token through to another service; 401 means a missing or bad token, 403 a valid token with too little scope; stdio servers take credentials from the environment.

With thousands of MCP clients, registering each one with each authorization server doesn't scale, while a URL-based identity does. The issuer check and credential binding close the mix-up attack, where a malicious authorization server tricks a client into sending a code or credentials meant for another one.

Message flow in 2026-07-28
ClientAuth serverClient's URLauthorize client_id=https://app…/client.jsonGET client metadata documentname, redirect_uris, …redirect: code + state + issClient checks iss == recorded issuer, or stopstoken request (code)
Solid arrows are requests, dashed are responses or notifications. Red ✕ is gone; green is new.

The redirect back to the client

2026-07-28
GET https://app.example.com/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=af0ifjsldkj    &iss=https%3A%2F%2Fauth.example.com// the client MUST compare iss with the issuer it started with before redeeming the code
What it used to look like (legacy, for comparison only)
Legacybefore
GET https://app.example.com/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=af0ifjsldkj

How the client identifies itself

2026-07-28
// CIMD: the client_id IS a URL the authorization server can fetchclient_id=https://app.example.com/oauth/client-metadata.json// which serves a document such as:{  "client_id": "https://app.example.com/oauth/client-metadata.json",  "client_name": "ExampleClient",  "redirect_uris": ["https://app.example.com/callback"]}
What it used to look like (legacy, for comparison only)
Legacybefore
// DCR (deprecated): register first, receive an opaque idPOST /register{ "client_name": "ExampleClient", "redirect_uris": ["https://app.example.com/callback"] }→ { "client_id": "s6BhdRkqt3" }

Illustrative values. See the client registration page for the required metadata fields.

  • Clients without an issuer check, or that share credentials across authorization servers, are now non-compliant and vulnerable to mix-up attacks.
  • Servers (resource servers) see little change, but if you also run the authorization server, plan CIMD support.
First time here? Set up the test helper (once per terminal)
ShellSetup
# 1. In a SECOND terminal, start the reference server (Node 18+, no dependencies)curl -sO https://www.diegozuluaga.dev/mcpa/reference-server.mjsnode reference-server.mjs                 # http://localhost:3000/mcp, logs appear here # 2. In THIS terminal, define the helper every test uses (bash or zsh)export MCP=http://localhost:3000/mcpMETA='"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}'mcp() {  # usage: mcp <method> '<json body>' [extra curl args...]  curl -sS -N "$MCP" \    -H 'Content-Type: application/json' \    -H 'Accept: application/json, text/event-stream' \    -H 'MCP-Protocol-Version: 2026-07-28' \    -H "Authorization: Bearer ${MCP_USER:-alice}" \    -H "Mcp-Method: $1" "${@:3}" -d "$2" \    -w '\nHTTP %{http_code}\n'}# Demo auth: the reference server treats the bearer token as the user's name.# Prefix a command with MCP_USER=bob to act as someone else.
ShellChecks
# Run these against your client with a test authorization server# 1a. Return a redirect with iss = a DIFFERENT issuer: the client must refuse to redeem the code# 1b. Advertise authorization_response_iss_parameter_supported, then omit iss: the client must reject# 2. (DCR or pre-registered clients only) Point the client at a new authorization server:#    it must register again, not reuse credentials. A CIMD client_id is portable.# 3. Call your MCP server with a valid token minted for ANOTHER audience: expect 401# 4. A valid token with too little scope: expect 403 with a WWW-Authenticate scope hint

Answer all 2 correctly and this lesson is marked as learned.

Q1Which check stops a mix-up attack?

Q2An authorization server's metadata advertises authorization_response_iss_parameter_supported: true, but a redirect arrives with no iss parameter. What must the client do?

Feedback or a correction? Email diego [at] diegozuluaga [dot] dev or open an issue on GitHub.

Content CC BY 4.0 · Code MIT

Tip: ← and → move between lessons. Hover any heading and press # to copy a link to it.