Agent Protocols Explained
← /learn · 06

Authorisation

The OAuth 2.1 flow an HTTP MCP server requires, step by step: discovery, registration, PKCE as SHA-256, the token's audience; and nine ways to break it, each failing at its own check.

Loading the animation…

Concept

A local MCP server over stdio runs as the user and needs no protocol-level sign-in. A server reached over HTTP does: it is a protected resource in OAuth's terms, and the client must get a token for it. The 2026-07-28 revision's authorization page (accessed 2026-10-08) builds the flow from OAuth 2.1 and five RFCs, and every step in the chart is one of them. With everything configured correctly the dance takes 26 steps, 16 of them HTTP messages.

Discovery. The first request has no token, so the server answers 401 with WWW-Authenticate: Bearer resource_metadata="…". The client reads the server's protected-resource metadata (RFC 9728), which names its authorisation server, then that server's own metadata (RFC 8414), and checks the issuer in it is the one it asked for.

Registration. The client needs a client_id at an authorisation server it has never met. In 2026-07-28 the preferred way is a Client ID Metadata Document: the client_id is an HTTPS URL, and the authorisation server fetches the client's metadata from it. Dynamic Client Registration, which the OAuth_for_MCP deck describes for the 2025-06 profile, is now deprecated but still allowed: choose Dynamic Client Registration to see it (25 steps).

PKCE and the code. The client makes a random code_verifier and sends only its hash, the code_challenge, with the user to the authorisation endpoint (RFC 7636). The resource parameter (RFC 8707) names the MCP server the token is for. On the way back the client checks state and the iss parameter (RFC 9207), then redeems the code with the verifier itself: the authorisation server hashes it and compares. A code stolen in transit is useless without the verifier, which never left the client.

The token's audience. The authorisation server stamps the resource into the token's aud. The MCP server MUST check the token was issued for it, and MUST NOT pass the token on to anything else.

Break it. Each "Break it" configuration fails at the check the spec puts there, and the outcome line says which rule stopped it: an authorisation server that does not advertise PKCE (the client MUST refuse, step 8); a client that leaves PKCE out (the authorisation server refuses, step 15); the wrong verifier (the code cannot be redeemed, step 22); metadata naming a different issuer (step 7); an iss mix-up on the way back (step 19); a token minted for another server (401, step 25); a scope too narrow for the tool (403 with the scope to ask for, step 26); and a server that forwards the client's token upstream, where the upstream API refuses a token not issued for it (step 28).

Maths

PKCE turns a secret the client never sends into a check the server can make later. With a verifier vv of 43 to 128 characters,

c=BASE64URL⁡(SHA256⁡(v))c = \operatorname{BASE64URL}\big(\operatorname{SHA256}(v)\big)

goes out with the user in the first leg; vv goes straight to the token endpoint in the second, and the authorisation server accepts the code only if it recomputes cc from vv. An attacker who intercepts the code needs a v′v' with SHA256⁡(v′)=SHA256⁡(v)\operatorname{SHA256}(v') = \operatorname{SHA256}(v): a preimage of SHA-256. Forty-three characters from the 66 the RFC allows carry about 43log⁡266≈26043 \log_2 66 \approx 260 bits, so guessing is out too.

The audience check is set membership: the server accepts a token tt only if resourceself∈aud(t)\text{resource}_{\text{self}} \in \text{aud}(t). Choose wrong audience: the token is valid, signed by the right issuer, unexpired, and still refused.

Code

The challenge, as the vendored engine computes it (sha256 is a pure-TS SHA-256 the tests check against Node's):

export function pkceChallenge(verifier: string): string {
  return b64url(sha256(utf8Bytes(verifier)));
}

The MCP server's audience check, at the step where wrong audience fails:

if (
  !check(
    "mcp",
    "token audience is this server",
    presentedClaims.aud === RESOURCE,
    "aud " + presentedClaims.aud + " vs " + RESOURCE,
    "MCP servers MUST validate that access tokens were issued specifically for them",
  )
) {