Over stdio the connection is trusted: the server is a local subprocess the client launched. Over Streamable HTTP it is not, and a remote server has to prove who is calling before it answers. MCP’s answer is OAuth 2.1, with the server cast as a resource server. Authorization is optional in the protocol, but when a server uses it, this is the shape. This post protects a real server, reads the discovery and the challenge on the wire, and covers the two rules that keep a server from leaking access it never meant to grant.
This is part of MCP on the Wire, a series that takes the Model Context Protocol apart message by message, in Go. It comes out of building and running MCP servers in production, including the open-source
txn2/mcp-data-platform, an Apache-2.0 platform in Go that connects AI assistants to Trino, DataHub, and S3 through one MCP endpoint, enriching every result with semantic context (ownership, lineage, PII, data quality) behind OAuth 2.1 auth, personas, and an audit trail. Everything here is read straight off the wire against spec revision2025-11-25, with the official Go SDK atv1.6.1.
§The Server Is a Resource Server
The roles come straight from OAuth 2.1. The MCP server is a resource server: it holds the protected thing and validates tokens, but it does not issue them. The MCP client is the OAuth client. A separate authorization server, which may or may not be co-hosted, handles the user and mints the tokens. This applies only to the HTTP transport; a stdio server takes its credentials from the environment and ignores all of this.
In Go, protecting the word_count HTTP server is a middleware around the MCP handler, plus a token verifier:
guard := auth.RequireBearerToken(verify, &auth.RequireBearerTokenOptions{
ResourceMetadataURL: base + "/.well-known/oauth-protected-resource",
Scopes: []string{"mcp:use"},
})
mux.Handle("/mcp", guard(mcpHandler))
Call that endpoint with no token, and the gate closes before the request reaches MCP at all:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="http://127.0.0.1:8081/.well-known/oauth-protected-resource", scope="mcp:use"
§Discovery: Finding the Authorization Server
That 401 is not a dead end, it is the start of discovery. The WWW-Authenticate header carries two things the client needs: a resource_metadata URL and the scope required. The client fetches the metadata, which the server serves per RFC 9728:
{ "resource": "http://127.0.0.1:8081/mcp",
"authorization_servers": ["https://auth.example.com"],
"scopes_supported": ["mcp:use"] }
Now the client knows which authorization server to talk to. A server must publish this, both as the WWW-Authenticate pointer on a 401 and at the well-known path, and a client must support both, preferring the header and falling back to probing /.well-known/oauth-protected-resource. From the authorization_servers URL, the client fetches the authorization server’s own metadata, trying the RFC 8414 and OpenID Connect Discovery well-known endpoints in priority order, to learn the authorization and token endpoints. The whole chain, 401 to resource metadata to AS metadata to token, is the sequence diagram at the top of this post.
§Getting a Token
With the endpoints in hand, the client runs an OAuth 2.1 authorization-code flow, and three details are mandatory in MCP.
PKCE is required. The client must use PKCE with the S256 challenge method, and must first confirm the authorization server advertises code_challenge_methods_supported. If that field is absent, the client must refuse to proceed rather than fall back to a weaker flow.
The resource parameter is required. Per RFC 8707, the client must send a resource parameter in both the authorization request and the token request, set to the canonical URI of the MCP server it intends to use the token at:
&resource=https%3A%2F%2Fmcp.example.com%2Fmcp
The client must send this whether or not the authorization server supports it, because it is what binds the resulting token to one server, the point the security section returns to.
Registration has a priority order. A client that has pre-registered credentials uses them. Otherwise it prefers Client ID Metadata Documents, a newer mechanism where the client_id is an HTTPS URL pointing at a JSON document of client metadata, which fits the common case of a client and server that have never met. Dynamic Client Registration is the fallback after that, kept for backwards compatibility, and prompting the user is the last resort.
§Using the Token
Once the client has a token, every request carries it in the Authorization header, never in the query string. The same server that rejected the tokenless request answers the authorized one:
POST /mcp Authorization: Bearer valid-token -> HTTP 200
The four outcomes, all captured from the protected server, are the whole runtime contract:
WWW-Authenticate with resource_metadata and scopeerror="insufficient_scope", the scopes to request§The Two Bans
Underneath the flow are two prohibitions that carry most of the security weight, and they are worth stating plainly because skipping either turns a useful server into an open door.
A server must validate the token’s audience. It must accept only tokens that were issued specifically for it, and reject any token issued for another service, even a valid one. This is what the resource parameter from the token request is for: it stamps the token with its intended audience, and the server checks that stamp. Without this, a token a user granted to some other service could be replayed against the MCP server and accepted.
A server must not pass a token through. When an MCP server calls an upstream API on the user’s behalf, it does not forward the client’s token. It obtains its own separate token for the upstream service, acting as an OAuth client there, often through the URL-mode elicitation flow from earlier in the series. The client’s token is for the MCP server and stops at the MCP server.
Break both at once and you have built the confused deputy: a server that accepts tokens meant for elsewhere and forwards them onward lets an attacker borrow the server’s trust to reach a downstream API. The resource indicator and the passthrough ban exist precisely so that a token is good at exactly one door and no other.
§Step-Up Scopes
A token does not have to carry every permission up front. When a request needs a scope the token lacks, the server answers 403 with an insufficient_scope challenge naming the scopes required:
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope", resource_metadata="...", scope="mcp:use"
The client reads the required scopes, runs the authorization flow again to get a token that includes them, and retries the original request, with a retry limit so a misconfiguration cannot loop forever. This is least privilege in practice: a client starts with the minimal scopes from scopes_supported and escalates only when a specific operation demands more. The SDK’s RequireBearerToken gate emits the scope and resource_metadata challenge at v1.6.1 but not the error parameter, so add error="insufficient_scope" yourself to stay RFC 6750 compliant.
§The Go Side
The server pieces are three: auth.RequireBearerToken for the gate, auth.ProtectedResourceMetadataHandler for the RFC 9728 document, and a TokenVerifier that, in production, validates the token’s signature and, critically, its audience. The verifier returns a TokenInfo with the token’s scopes and an optional UserID, which the transport can use to bind a session to one user and resist hijacking.
On the client side, a detail worth flagging: when the official Go SDK reached v1.0, client-side OAuth was the one part of the spec it did not yet cover. It does now. The SDK ships an AuthorizationCodeHandler, configuration for Client ID Metadata Documents, and Dynamic Client Registration, so a Go client can walk the discovery chain and the token flow without hand-rolling it. The wire is what this post showed; the SDK drives it.
This is the path txn2/mcp-data-platform takes in production. It is an OAuth 2.1 resource server in front of an OIDC provider, Keycloak, Auth0, or Okta, with PKCE for public clients and Dynamic Client Registration, and its default-deny personas map a verified identity to an allowed set of tools. A missing or invalid credential fails closed: access is denied, never bypassed.
§What’s Next
Authorization controls who reaches a server. It does nothing about what a server does once reached, and a server is the one party in an MCP session whose words flow straight into the model’s context. The next post, Security: Tool Poisoning, Prompt Injection, and the Trust Boundary, reads that threat at the wire level: how a tool description or result becomes an injection, why the annotations from the tools post cannot be trusted, and where the host has to stand to keep a server it does not control from speaking for the user.
The production data platform behind this series is txn2/mcp-data-platform, available hosted as Plexara.