MCP Protocol Internals · Lesson 3

HTTP Transport Authentication

How a Streamable HTTP MCP server decides whether you're even allowed to call a tool — OAuth 2.1, decoded from a real challenge against a production server.

In Lesson 2 we called get-env on our local server and it just answered — anyone who can reach the port can run any tool. That's fine for a reference server on 127.0.0.1. It is not fine for a server on the public internet holding your company's data. This lesson is about the gate: how an MCP server says "prove who you are first," and exactly what bytes cross the wire to satisfy it.

The one-sentence mental model

MCP invents no auth of its own — it is plain OAuth 2.1. The MCP server plays the role of an OAuth Resource Server: it doesn't take your password, it demands a Bearer access token and, if you don't have one, tells you exactly where to go get it. Everything below is OAuth machinery layered on top of the Streamable HTTP transport you already know.spec

Authentication vs authorization. Authn = who you are (do you hold a valid token?). Authz = what you may do (does that token carry the scopes for this tool?). MCP leans on OAuth for both. Watch for it: a missing token is a 401; a valid token lacking the right scope is a 403.

1 · Auth is optional at the transport — so two servers disagree

The spec makes HTTP authorization OPTIONAL: a server MAY require it, MAY not. (stdio servers don't use it at all — they get credentials from the environment.) The fastest way to feel this is to send the same initialize to two real servers. Our local everything server requires nothing:

POST http://127.0.0.1:3001/mcp (no Authorization header) { "jsonrpc":"2.0","id":1,"method":"initialize", … } HTTP/1.1 200 OK ← opens a session for anyone. No gate at all.

Now the identical request to Linear's production MCP server (https://mcp.linear.app/mcp) — a real capture:

POST https://mcp.linear.app/mcp Content-Type: application/json Accept: application/json, text/event-stream { "jsonrpc":"2.0","id":1,"method":"initialize", … } (no Authorization header) HTTP/2 401 www-authenticate: Bearer realm="OAuth", resource_metadata="https://mcp.linear.app/.well-known/oauth-protected-resource/mcp", error="invalid_token", error_description="Missing or invalid access token" content-type: application/json { "error":"invalid_token", "error_description":"Missing or invalid access token" }
The 401 is not a dead end — it's a treasure map

A bare 401 would just say "no." The MCP/OAuth twist is the WWW-Authenticate header carrying resource_metadata=… — a URL that tells the client how to get authorized. This pointer is the heart of RFC 9728 (Protected Resource Metadata), and it is what lets a generic MCP client authenticate against a server it has never seen before, with zero manual configuration.RFC 9728

2 · Follow the pointer — Protected Resource Metadata (RFC 9728)

The client GETs the URL from that header. No auth needed — discovery is public. Real capture:

GET https://mcp.linear.app/.well-known/oauth-protected-resource/mcp HTTP/2 200 { "resource": "https://mcp.linear.app/mcp", ← canonical id of THIS server "authorization_servers": [ "https://mcp.linear.app" ], ← go HERE to get a token "scopes_supported": [ "read", "write" ], ← the permissions on offer "bearer_methods_supported": [ "header" ] } ← send token in Authorization: Bearer

Three fields do the heavy lifting. authorization_servers names who issues tokens for this resource (it need not be the same host as the MCP server — separation of concerns). resource is the server's canonical identifier; remember it, it returns in Section 5. And scopes_supported is the menu of permissions — the vocabulary that later gates individual tools.

3 · Ask the authorization server how it works — RFC 8414

The client now knows the authorization server is https://mcp.linear.app. It fetches that server's metadata to learn its endpoints — again public, again a real capture:

GET https://mcp.linear.app/.well-known/oauth-authorization-server HTTP/2 200 { "issuer": "https://mcp.linear.app", "authorization_endpoint": "https://mcp.linear.app/authorize", ← browser sends the user here "token_endpoint": "https://mcp.linear.app/token", ← code is exchanged here "registration_endpoint": "https://mcp.linear.app/register", ← Dynamic Client Registration (RFC 7591) "scopes_supported": [ "read", "write" ], "grant_types_supported": [ "authorization_code", "refresh_token", … ], "response_types_supported": [ "code" ], "code_challenge_methods_supported": [ "S256" ], ← PKCE is mandatory "client_id_metadata_document_supported": true }
Two things that make this "OAuth 2.1", not classic OAuth

PKCE is required. code_challenge_methods_supported: ["S256"] means every client — even a confidential one — must use Proof Key for Code Exchange: it sends a SHA-256 hash of a random secret up front and the raw secret at token time, so a stolen authorization code is useless to a thief. And there's no pre-registration step: a registration_endpoint (RFC 7591 Dynamic Client Registration) lets a client the server has never met register itself on the fly. This is why you can point a brand-new MCP client at any compliant server and have it work without an admin issuing you a client_id by hand.spec

4 · The browser dance — authorization code + PKCE + the resource binding

Now the client has every endpoint it needs. The next steps involve a human in a browser, so we describe the shape rather than capture them — but every value below is built deterministically from the metadata you just saw. First the client (after registering) sends the user's browser to the authorization_endpoint:

GET https://mcp.linear.app/authorize? response_type=code &client_id=…(from registration)… &redirect_uri=http://localhost:8765/callback &code_challenge=BASE64URL(SHA256(verifier)) ← PKCE: the hash, not the secret &code_challenge_method=S256 &scope=read%20write ← which permissions to request &resource=https://mcp.linear.app/mcp ← RFC 8707: bind token to THIS server

The user logs in and consents in their browser; the server redirects back to redirect_uri with a one-time code. The client exchanges it — proving it started the flow by revealing the PKCE code_verifier — at the token endpoint:

POST https://mcp.linear.app/token Content-Type: application/x-www-form-urlencoded grant_type=authorization_code&code=…&code_verifier=…(the raw secret)… &redirect_uri=http://localhost:8765/callback&resource=https://mcp.linear.app/mcp ── representative response shape ── { "access_token":"…", "token_type":"Bearer", "expires_in":3600, "refresh_token":"…", "scope":"read write" }
Why the resource parameter is the most important field here

That resource=https://mcp.linear.app/mcp (RFC 8707 Resource Indicators) tells the authorization server: "mint a token whose audience is this one MCP server." The 2025 MCP spec makes this a MUST, and it closes the confused-deputy hole: without it, a malicious MCP server could take the token you handed it and replay it against a different server that trusts the same issuer. Bound to an audience, a token leaked to server A is rejected by server B. The flip side is a matching rule for servers: a resource server MUST validate that a presented token was actually issued for it and MUST NOT pass tokens through to upstream APIs. "It's a valid token" is never enough — it has to be a valid token for this audience.spec

5 · The authenticated request — and how it differs from the session

With a token in hand, the client retries the very first request — now it carries one extra header, and this time it gets a session instead of a 401:

POST https://mcp.linear.app/mcp Authorization: Bearer eyJhbGci… ← the access token from step 4 Content-Type: application/json Accept: application/json, text/event-stream { "jsonrpc":"2.0","id":1,"method":"initialize", … } HTTP/2 200 mcp-session-id: … ← now we're in — session opens as in Lesson 1
Two different headers, two different jobs

Don't conflate them. Authorization: Bearer answers "who are you and what may you do?" — it's checked on every request and is pure OAuth. Mcp-Session-Id (Lesson 1) answers "which ongoing conversation is this?" — it's MCP transport state, not a credential. A token can outlive many sessions; a session is meaningless without the token that earned it. From here on, every tools/call rides with the Authorization header, and the server checks the token's scopes against the tool before running it — that's where read vs write finally bites.

Spec says MUST — reality may differ (again)

Same lesson as the Origin check in Lesson 1: the spec is full of MUSTs here — require PKCE, validate token audience, never pass tokens through — but those bind implementers, not the server in front of you. Plenty of real MCP servers ship with auth misconfigured or audience validation skipped. When you read auth traffic, verify the gate is actually doing its job; don't assume a 401 on the front door means the locks inside are sound.

🔧 Try it yourself (≈ 6 minutes)

You don't need a Linear account — the challenge and the whole discovery chain are public. Walk the map by hand:

# 1 · trigger the 401 and read the WWW-Authenticate header: curl -s -i -X POST https://mcp.linear.app/mcp \ -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}' \ | grep -i www-authenticate # 2 · follow resource_metadata → who issues tokens, what scopes exist: curl -s https://mcp.linear.app/.well-known/oauth-protected-resource/mcp | jq # 3 · ask that authorization server for its endpoints + PKCE methods: curl -s https://mcp.linear.app/.well-known/oauth-authorization-server | jq # or let the driver walk the whole chain for any MCP url: bash assets/mcp-curl.sh discover https://mcp.linear.app/mcp

Predict before you run: which step needs a token, and which are public? Then try the same discover against our local everything server (http://127.0.0.1:3001/mcp) — what happens, and what does that tell you about its security posture?

Check yourself

An MCP server returns 401 with WWW-Authenticate: Bearer … resource_metadata="…". What is that resource_metadata URL for?
Why does the spec make the resource parameter (RFC 8707) a MUST on the authorization and token requests?
How do Authorization: Bearer and Mcp-Session-Id differ?
Primary source — read this next

MCP Specification 2025-11-25 — Base / Authorization. You've now seen the real 401 challenge, both .well-known metadata documents, and where PKCE and the resource parameter fit. Read the "Authorization Server Discovery" and "Security Considerations" sections — and skim RFC 9728 (Protected Resource Metadata) and RFC 8707 (Resource Indicators), the two RFCs doing the new work here.