An AI agent that wants to call a tool on a remote MCP server has the same problem any HTTP client has: the server needs to know who is asking and whether they are allowed. The Model Context Protocol’s answer is not a bespoke scheme. It is OAuth — specifically OAuth 2.1 with PKCE, layered on a small stack of IETF metadata standards so that a client can walk up to a server it has never seen, discover how to authenticate, register itself, and obtain a token, all without a human pre-configuring anything. This guide walks through that machinery the way an implementer needs to understand it: the roles, the discovery handshake, the PKCE flow step by step, dynamic client registration, and the pitfalls that bite people. It is grounded in the MCP authorization specification (revision 2025-06-18) and the RFCs it builds on.
Why OAuth at all
MCP separates two things that older API keys conflate: the identity of the party making the call, and the secret used to make it. A static bearer token answers “does the caller hold the secret?” but nothing about “on whose behalf, and scoped to what.” OAuth exists precisely to model delegated authority — a resource owner (a human) granting a client (the agent) a scoped, expiring, revocable token to act against a resource (the MCP server) — without ever handing the client the owner’s password. That is the shape of the agent problem. An agent acts for a person; it should carry a token that says so, that can be narrowed, that expires, and that can be pulled without rotating a shared secret across every integration.
The spec is careful about scope. Authorization in MCP is OPTIONAL, and it applies to HTTP-based transports. Servers spoken to over stdio are told not to use this flow and to take credentials from the environment instead — the OAuth dance is for the networked case, where a client and a server that do not know each other need a standard way to establish trust. When a server does opt in, the MCP spec does not reinvent OAuth; it profiles it, adopting “a selected subset of their features to ensure security and interoperability while maintaining simplicity.”
The roles, precisely
OAuth’s power for MCP comes from a clean split of responsibilities. Four roles matter, and keeping them straight is most of the battle:
- MCP client — the agent-side software making the request (a desktop app, an IDE extension, a CLI). It is the OAuth client, acting on behalf of a resource owner. Because these are typically apps a user runs, they are public clients: they cannot keep a client secret, which is exactly why PKCE is mandatory.
- MCP server — the tool-hosting endpoint. It is the OAuth resource server: it accepts requests bearing an access token, validates them, and serves or refuses. It does not run the login UI.
- Authorization server (AS) — the party that authenticates the user, obtains their consent, and issues tokens. The spec deliberately holds this at arm’s length: “The implementation details of the authorization server are beyond the scope of this specification. It may be hosted with the resource server or a separate entity.” That one sentence is the headline change of the 2025-06-18 revision — the resource server and the authorization server are cleanly separated, so an MCP server can validate tokens minted by an external identity provider rather than being forced to run an OAuth server itself.
- Resource owner — the human whose data or permissions are in play, and who clicks “allow” in the browser.
The rest of this guide is really just the story of how a client, starting from nothing but a server URL, finds the authorization server, proves itself, and comes back with a token the server will accept.
Discovery: from a 401 to an authorization server
The client is not expected to be told, out of band, where to log in. It discovers that. The mechanism is a chain of two metadata standards, kicked off by an ordinary unauthorized response.
Step 1 — the challenge. The client makes an MCP request with no token. The server, being a well-behaved OAuth resource server, answers 401 Unauthorized and includes a WWW-Authenticate header pointing at its protected resource metadata. This is RFC 9728, OAuth 2.0 Protected Resource Metadata, and the MCP spec makes it non-negotiable: MCP servers MUST implement it, and clients MUST be able to parse the WWW-Authenticate header and act on the 401.
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata=
"https://mcp.example.com/.well-known/oauth-protected-resource"
Step 2 — the protected resource metadata. The client fetches that document. Its job is to name the authorization server(s) that issue tokens for this resource, in the authorization_servers field, which MUST contain at least one entry. A document may list several; per RFC 9728 §7.6 it is the client’s job to choose.
GET /.well-known/oauth-protected-resource HTTP/1.1
Host: mcp.example.com
{
"resource": "https://mcp.example.com/mcp",
"authorization_servers": ["https://auth.example.com"]
}
Step 3 — the authorization server metadata. Now the client knows which AS to talk to, but not its endpoints. It resolves them through RFC 8414, OAuth 2.0 Authorization Server Metadata, fetching the AS’s well-known document to learn its authorization endpoint, token endpoint, registration endpoint, and supported capabilities. Both sides are bound here: authorization servers MUST provide RFC 8414 metadata, and MCP clients MUST use it.
GET /.well-known/oauth-authorization-server HTTP/1.1
Host: auth.example.com
{
"issuer": "https://auth.example.com",
"authorization_endpoint": "https://auth.example.com/authorize",
"token_endpoint": "https://auth.example.com/token",
"registration_endpoint": "https://auth.example.com/register",
"code_challenge_methods_supported": ["S256"]
}
Two hops, two well-known documents, and the client has gone from a bare server URL to a full map of where to register and where to get a token — with no human in the configuration loop. That automatic-discovery property is the whole reason MCP leans on this metadata stack rather than expecting every developer to paste endpoints by hand.
Dynamic client registration
There is still a gap. To run an OAuth flow the client needs a client_id registered with this authorization server — and by design it may be meeting this AS for the first time. RFC 7591, OAuth 2.0 Dynamic Client Registration, closes it: the client POSTs its metadata (redirect URIs, a name, the grant types it wants) to the registration endpoint and gets back a freshly minted client_id.
POST /register HTTP/1.1
Host: auth.example.com
Content-Type: application/json
{
"client_name": "Example Agent",
"redirect_uris": ["http://localhost:33418/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"token_endpoint_auth_method": "none"
}
The MCP spec says clients and authorization servers SHOULD support DCR — a “should,” not a “must,” and the distinction matters. DCR is what lets an agent connect to a server nobody hardcoded for it: the spec notes clients “may not know all possible MCP servers and their authorization servers in advance,” and manual registration “would create friction.” But an AS is free not to offer it. When it doesn’t, the spec’s fallback is explicit: the client either ships a hardcoded client_id for that AS, or presents a UI where the user pastes credentials they registered by hand. Note token_endpoint_auth_method: "none" above — the honest declaration of a public client that holds no secret, which is why the next step exists.
PKCE: the authorization-code flow, step by step
With a client_id in hand the client runs the OAuth 2.1 authorization-code grant, hardened with PKCE — Proof Key for Code Exchange, originally RFC 7636 and folded into OAuth 2.1 as a requirement for every client. The MCP spec restates it plainly: clients MUST implement PKCE. Here is why it is not optional, and how each step contributes.
The threat PKCE defends against is authorization-code interception. In a public client there is no client secret at the token endpoint, so if an attacker can grab the authorization code in transit — a malicious app registered on the same custom URI scheme, a leaky redirect, a logged URL — they could redeem it for a token. PKCE binds the code to a secret the client generates fresh for each flow, so a stolen code is useless without it.
- Generate a verifier and challenge. Before starting, the client creates a high-entropy random string, the
code_verifier. It hashes it to produce thecode_challenge:code_challenge = BASE64URL(SHA-256(code_verifier)), advertised as methodS256. The verifier never leaves the client until the very last step. - Authorization request. The client opens the user’s browser to the AS’s authorization endpoint, carrying the
code_challenge, thecode_challenge_method=S256, itsclient_id,redirect_uri, astatevalue, and — MCP-specific — theresourceparameter (covered in the next section).GET /authorize?response_type=code &client_id=s6BhdRkqt3 &redirect_uri=http%3A%2F%2Flocalhost%3A33418%2Fcallback &code_challenge=E9Melhoa2Ow...c8xGYb0 &code_challenge_method=S256 &state=af0ifjsldkj &resource=https%3A%2F%2Fmcp.example.com%2Fmcp HTTP/1.1 Host: auth.example.com - The user authenticates and consents. The AS runs its own login and shows a consent screen — the one place a human is in the loop. This is the delegation moment: the resource owner grants the agent scoped access.
- Authorization code callback. The AS redirects back to the client’s
redirect_uriwith a short-livedcodeand the echoedstate. The client MUST check thestatematches the value it sent and discard the response otherwise — that is the CSRF and mix-up defense. - Token request with the verifier. The client POSTs the
codeto the token endpoint, and now includes the rawcode_verifier. The AS hashes it and compares to thecode_challengeit stored at step 2. A match proves the party redeeming the code is the same party that started the flow.POST /token HTTP/1.1 Host: auth.example.com Content-Type: application/x-www-form-urlencoded grant_type=authorization_code &code=SplxlOBeZQQYbYS6WxSbIA &redirect_uri=http%3A%2F%2Flocalhost%3A33418%2Fcallback &client_id=s6BhdRkqt3 &code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk &resource=https%3A%2F%2Fmcp.example.com%2Fmcp - Tokens issued. The AS returns an
access_tokenand usually arefresh_token. The client attaches the access token to every subsequent MCP request asAuthorization: Bearer <token>.
The elegance is that the authorization code — the thing most likely to leak, since it travels through a browser redirect — is worthless on its own. Only the holder of the matching code_verifier can spend it.
The resource parameter: binding a token to one server
This is the part implementers most often miss, and it is the security hinge of the whole design. An access token should be usable only at the server it was minted for; otherwise an MCP server that receives a token could replay it against a different service, or be tricked into forwarding it — the “confused deputy” problem. MCP forecloses this with RFC 8707, Resource Indicators for OAuth 2.0.
Clients MUST include a resource parameter in both the authorization request and the token request, and it MUST be the canonical URI of the intended MCP server — and clients MUST send it even if a given AS does not appear to support it, so that servers which do enforce audience binding always have it. The canonical URI is the server’s identifier with scheme and host, no fragment. https://mcp.example.com/mcp is valid; mcp.example.com (no scheme) and https://mcp.example.com#frag (fragment) are not. The spec advises the most specific URI the client can give and, for interoperability, the form without a trailing slash.
The payoff is on the server side. An MCP server MUST validate that the token presented to it was issued specifically for it as the audience, and MUST reject tokens that do not name it. And the rule that prevents the confused-deputy chain: if the MCP server itself calls an upstream API, it acts as an OAuth client there with a separate token from the upstream’s own AS — it MUST NOT pass through the token it received from the agent. Token passthrough is explicitly forbidden. Audience binding plus no-passthrough is what keeps a token from becoming a skeleton key across services.
Common pitfalls
The flow is standard, but a handful of mistakes recur. Each maps to a “MUST” in the spec:
- Skipping the
resourceparameter. The most common. A client that omits it works fine against lenient servers and then fails the instant it meets one enforcing audience binding — or worse, silently obtains an over-broad token. Send it always, in both requests. - Accepting tokens by signature alone. A resource server that verifies a token is well-formed and validly signed but never checks the audience claim will accept tokens minted for other services. Validate the audience, per RFC 9068 / RFC 8707, before doing anything with the request.
- Token passthrough to upstreams. Reusing the agent’s token when calling a downstream API turns the server into a confused deputy. Get a distinct token from the upstream’s AS.
- Loose redirect URIs. Redirect URIs MUST be pre-registered and matched exactly by the AS — no wildcards, no prefix matching. And every redirect URI MUST be
localhostor HTTPS. Loopback redirects are how native agents catch the callback. - Dropping
state. Not verifying the returnedstateagainst the sent value reopens CSRF and mix-up attacks. Generate it, check it, discard on mismatch. - Tokens in the URL. Access tokens MUST NOT appear in a query string; they go in the
Authorizationheader on every request, even within one logical session. - Long-lived tokens, no rotation. Authorization servers SHOULD issue short-lived access tokens, and for public clients MUST rotate refresh tokens. A leaked short token expires; a rotated refresh token limits how long a stolen one is good for.
- Assuming DCR exists. It is a SHOULD, not a MUST. Build the hardcoded-
client_idor user-supplied-credential fallback for authorization servers that do not offer registration.
Where a gateway fits
Everything above is the protocol as written; it says nothing about who plays each role. That is the honest place to note where Meandr sits. Meandr is an MCP gateway, and in this vocabulary it can act as the authorization server to agents — running the discovery metadata, the registration endpoint, and the PKCE authorization-code flow so an agent authenticates to Meandr with a real OAuth token rather than a pasted secret. Claude Desktop connects to Meandr this way today: you sign in from Desktop over OAuth and calls route through the gateway, with no bearer token to copy into a header field. What the agent holds toward the upstream MCP servers is then Meandr’s problem, not the agent’s — which is the same separation-of-credentials idea this spec is built on, applied one hop further. If you are implementing the upstream side, that split is the subject of signing AWS SigV4 without a proxy, where the gateway holds the credential the agent never sees.
Reference
The normative sources, worth reading directly if you are building either side of this:
- MCP Authorization specification (revision 2025-06-18) — the profile that ties the RFCs below together.
- OAuth 2.1 (draft-ietf-oauth-v2-1-13) — the base grant and security model; still an IETF draft.
- RFC 7636 — Proof Key for Code Exchange (PKCE).
- RFC 9728 — OAuth 2.0 Protected Resource Metadata.
- RFC 8414 — OAuth 2.0 Authorization Server Metadata.
- RFC 7591 — OAuth 2.0 Dynamic Client Registration Protocol.
- RFC 8707 — Resource Indicators for OAuth 2.0.
For how tokens and secrets are handled once an agent is talking through a gateway, see MCP authentication.