Authorization: how a client earns a token for exactly one server
For HTTP servers, MCP uses OAuth 2.1: the MCP server is a resource server, and every token must be minted for it and checked by it. Learn the flow once and a quarter of the exam gets easier.
A company runs dozens of internal MCP servers: the lunch menu, the wiki, and HR records. All of them accept "any valid company token".
Someone builds a sloppy lunch-menu client that logs tokens. An attacker reads the log, takes a token, and calls the HR server with it. It works, because the HR server only checked that the token was valid, not that it was issued for the HR server.
Under MCP's rules this fails twice. The client must ask for a token for one specific server (the resource parameter), and the HR server MUST reject tokens whose audience isn't itself. A token stolen from the lunch-menu server opens only the lunch menu. And the HR server starts with files:read only; writing needs a second, explicit step-up to files:write.
Challenge. The client calls without a token; the server returns 401 with WWW-Authenticate: Bearer resource_metadata="…" (and SHOULD include scope). Servers MUST implement Protected Resource Metadata; clients MUST support both the header and the well-known URLs (/.well-known/oauth-protected-resource/<path>, then the root).
Protected Resource Metadata (RFC 9728) MUST list at least one authorization_servers entry.
Authorization server metadata via RFC 8414 or OpenID Connect discovery; clients MUST support both, and the issuer in the document MUST match the one used to build the URL.
Register: pre-registered, then Client ID Metadata Document, then Dynamic Client Registration (deprecated), then ask the user.
Authorize with PKCE: clients MUST use S256 when they can, and MUST refuse to proceed if the metadata has no code_challenge_methods_supported. Clients MUST send the resource parameter (RFC 8707) with the server's canonical URI in both the authorization and token requests, whether or not the authorization server supports it. Check iss on the way back (lesson 12 of track 1).
Use the token: Authorization: Bearer … on every request. Tokens MUST NOT be in the query string.
Validate: servers MUST check the token was issued for them. Invalid or expired → 401; valid but insufficient scope → 403 with error="insufficient_scope"; malformed request → 400.
Step up: on 403 insufficient_scope, the client re-authorizes with the union of its old scopes and the challenged ones, and retries a limited number of times.
Two hard rules: clients MUST NOT send the server any token not issued by its authorization server, and servers MUST NOT pass a client's token through to an upstream API: they get their own. Authorization is optional overall; stdio servers SHOULD use credentials from the environment instead.
The audience check is what makes a stolen token worthless elsewhere, and the resource parameter is how the client asks for a token that will pass that check. PKCE stops intercepted codes being redeemed. Small initial scopes plus step-up mean a leaked token can do little.
No audience check turns every leaked token into a master key (the story above).
Token passthrough: forwarding the client's token to GitHub, Slack or a database breaks audit trails and lets clients reach APIs they were never granted.
401 vs 403 mix-ups break step-up: a client that gets 401 for a scope problem re-authenticates forever instead of asking for the missing scope.
Huge scopes up front (files:*, admin:*) make every leak catastrophic. Start minimal and step up.
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.
The reference server has an OAuth-protected endpoint at /secure/mcp with three demo tokens: token-read, token-write and token-other (wrong audience).
ShellTests
S=http://localhost:3000/secure/mcpsec() { # usage: sec <token> <method> '<json body>' [extra curl args] curl -sS -i "$S" -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \ -H 'MCP-Protocol-Version: 2026-07-28' -H "Mcp-Method: $2" -H "Authorization: Bearer $1" "${@:4}" -d "$3"}# 1. No token: 401 with resource_metadata, then follow itcurl -sS -i "$S" -X POST -H 'Content-Type: application/json' -d '{}' | grep -i 'HTTP/\|www-authenticate'curl -sS http://localhost:3000/.well-known/oauth-protected-resource/secure/mcp; echo# 2. A valid token for ANOTHER server: rejected by the audience check (401)sec token-other tools/list '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{'"$META"'}}' | grep -i 'HTTP/\|www-authenticate'# 3. Read-only token: tools/list only shows what this token can usesec token-read tools/list '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{'"$META"'}}' | grep -o '"name":"[a-z_]*"'# 4. Read-only token tries to write: 403 insufficient_scope (step-up challenge)sec token-read tools/call '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"write_file","arguments":{"path":"a.txt","text":"hi"},'"$META"'}}' -H 'Mcp-Name: write_file' | grep -i 'HTTP/\|www-authenticate'# 5. After step-up (token-write): it workssec token-write tools/call '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"write_file","arguments":{"path":"a.txt","text":"hi"},'"$META"'}}' -H 'Mcp-Name: write_file' | tail -1; echo# 6. Token in the query string is ignored: 401curl -sS -i "$S?access_token=token-write" -X POST -H 'Content-Type: application/json' -d '{}' | head -1