Chapter 19: Implementing OAuth
A common trap is to treat a successful browser login and code exchange as done. Then the first real client arrives and the system falls apart because discovery is hard-coded, the token audience is wrong, refresh logic is bolted on later, or a 401 response gives the client no way to recover automatically.
For MCP, OAuth implementation is part of server interoperability as well as an identity-provider integration task. A protected MCP server has to tell clients where to learn about authorization, validate tokens against the correct resource, map authenticated identity into actual policy, and fail in ways that let clients try again intelligently.
The rule for this chapter: every rejection tells the client what to do next, and every acceptance names the principal the server acted for. A 401 that points nowhere breaks the first half. A tool that forwards the caller’s token to another API breaks the second.
This chapter implements the tickets server from Chapter 18 (https://mcp.example.com/mcp, authorization server auth.example.com) in the order its requests cross the wire:
Client MCP server (mcp.example.com) AS (auth.example.com)
|-- POST /mcp (no token) ------------->| |
|<-- 401 + resource_metadata ----------| |
|-- GET /.well-known/oauth-protected-resource/mcp -->| |
|-- GET /.well-known/oauth-authorization-server ----------------------->|
| (registration: pre-registered, CIMD, or DCR) |
|-- browser: /authorize?...&resource=... ------------------------------>|
|<-- /callback?code=...&iss=... (check iss) ----------------------------|
|-- POST /token (code_verifier, resource) ----------------------------->|
|-- POST /mcp (Bearer) --------------->| validate, map claims, policy |
|<-- 200, or 403 insufficient_scope ---| (step-up, then retry once) |
The First Request Usually Fails, and That Is Fine
A well-behaved OAuth-protected MCP session often begins with failure.
The client connects without a token, or with an expired one. The MCP server rejects the request with 401 Unauthorized. That is the beginning of discovery, not a broken state.
What matters is whether the response teaches the client what to do next. Appendix B §What the Bytes Look Like has the exact header and metadata to compare against.
A useful WWW-Authenticate header should point the client to the protected resource metadata URL. It should also name the scopes the request needs, which the spec recommends so the client can ask for the least privilege up front, and say enough about the failure to support recovery:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp", scope="tickets:read", error="invalid_token", error_description="Access token expired"
Content-Type: application/json
If the request carried no token at all, leave out error and error_description; RFC 6750 says a server should not return an error code when no authentication was attempted.
That header is part of the protocol contract. The client can fetch resource metadata, learn which authorization server to use, obtain authorization server metadata, and then start or resume an OAuth flow.
A bare 401 with no metadata pointer forces the client onto private out-of-band configuration, and operators end up debugging what looks like a transport issue but is a discovery failure.
Discovery Tells the Client Where Authority Lives
The fastest way to create an OAuth integration that only works in demos is to hard-code the authorization server URL, token endpoint, and scope list into both sides. MCP clients are expected to discover how a protected server wants to be accessed. Two documents matter.
| Document | Owner | Where the client looks, in order |
|---|---|---|
| Protected resource metadata (RFC 9728) | MCP server | 1. the resource_metadata URL from the 4012. path form: https://mcp.example.com/.well-known/oauth-protected-resource/mcp3. root form: https://mcp.example.com/.well-known/oauth-protected-resource |
| AS metadata, issuer without a path | Authorization server | 1. https://auth.example.com/.well-known/oauth-authorization-server2. https://auth.example.com/.well-known/openid-configuration |
AS metadata, issuer https://auth.example.com/tenant1 | Authorization server | 1. /.well-known/oauth-authorization-server/tenant12. /.well-known/openid-configuration/tenant13. /tenant1/.well-known/openid-configuration |
MCP makes protected resource metadata mandatory for protected HTTP servers, and clients must support both RFC 8414 and OpenID Connect Discovery. Two exact-match checks close the chain. The resource value in the resource metadata must match the server the client is talking to (RFC 9728 §3.3). The issuer value inside the authorization server metadata must exactly match the issuer used to build the URL, or the client discards the document.
The authorization server metadata tells the client where the authorization endpoint, token endpoint and JWKS endpoint live, and whether the server supports Client ID Metadata Documents (client_id_metadata_document_supported), Dynamic Client Registration (registration_endpoint), and the RFC 9207 iss response parameter (authorization_response_iss_parameter_supported).
The result is a clean chain of authority: the client knows the MCP server URL, the MCP server names the authorization server, and the authorization server says how to run OAuth. Keeping those as separate documents stops the resource server and the authorization server from being treated as one thing.
The tickets server’s resource metadata is small:
{
"resource": "https://mcp.example.com/mcp",
"authorization_servers": ["https://auth.example.com"],
"scopes_supported": [
"tickets:read",
"tickets:write",
"deployments:read"
],
"bearer_methods_supported": ["header"]
}
deployments:write is deliberately absent. scopes_supported should list what basic use needs; the dangerous scope arrives later through a step-up challenge, when a user actually tries to delete a deployment. If resource or authorization_servers drift from reality, clients will request the wrong token, cache the wrong metadata, or bind the wrong audience.
Registration Comes Before the Browser Opens
A client needs a client_id before it can send anyone to /authorize. The 2026-07-28 authorization spec says clients and authorization servers should support OAuth Client ID Metadata Documents (CIMD). It deprecates Dynamic Client Registration (DCR), which remains a MAY for backwards compatibility. Both reduce manual setup for clients connecting to unfamiliar MCP servers. CIMD does it without a registration endpoint and without one stored client per connection. The order a client tries them in is a tree in Appendix L.
A CIMD client hosts a JSON document at an HTTPS URL with a path, and uses that URL as its client_id:
{
"client_id": "https://app.example.com/oauth/client-metadata.json",
"client_name": "Example MCP Client",
"redirect_uris": ["http://127.0.0.1:8090/callback", "http://localhost:8090/callback"],
"grant_types": ["authorization_code"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
}
The authorization server fetches the document and checks three things: that client_id matches the URL exactly, that the requested redirect_uri is listed, and that the document is well formed. It should cache the result according to HTTP cache headers.
Some enterprises require pre-provisioned clients, reviewed redirect URIs, and explicit approval before any registration happens. So implement in the spec’s priority order:
- use pre-registered client credentials for that authorization server if you have them;
- otherwise use CIMD if the authorization server advertises
client_id_metadata_document_supported; - otherwise fall back to DCR if it advertises a
registration_endpoint, sending an appropriateapplication_type(nativefor desktop, CLI, and localhost clients); - otherwise give operators a clear way to supply a client ID and redirect URIs.
Whichever path you take, store credentials keyed by the authorization server’s issuer, and never reuse them with a different authorization server. CIMD client IDs are the exception: they are portable by design.
Registration errors should name the mode the client is in. A client that dies with a generic configuration error is hard to support. One that says “this authorization server supports neither Client ID Metadata Documents nor dynamic registration; configure a pre-registered client ID” is easy to operate.
The Client Runs Authorization Code With PKCE and Checks the Issuer
For remote user-facing MCP clients, the normal pattern is Authorization Code with PKCE. The client records the authorization server’s issuer, opens the user’s browser, sends them to the authorization endpoint, receives an authorization code on a callback URL, validates the response, and exchanges that code for an access token. MCP also requires use of the OAuth resource parameter from RFC 8707 so the authorization server knows which protected resource the token is for.
A minimal authorization request looks like this:
GET /authorize?
response_type=code&
client_id=mcp-client-123&
redirect_uri=http%3A%2F%2Flocalhost%3A8090%2Fcallback&
scope=tickets%3Aread%20tickets%3Awrite&
code_challenge=4L7d...&
code_challenge_method=S256&
resource=https%3A%2F%2Fmcp.example.com%2Fmcp&
state=af1b... HTTP/1.1
Host: auth.example.com
The callback is where a check is easy to skip. Since 2026-07-28, MCP clients must apply RFC 9207 before sending the code anywhere. If the callback carries an iss parameter, it must be a simple string match for the issuer the client recorded before redirecting. If the authorization server’s metadata sets authorization_response_iss_parameter_supported: true and iss is missing, the client rejects the response. On a mismatch the client must not act on, or display, any error parameters in the callback either. This closes the mix-up attack where a code from one authorization server is replayed to another.
GET /callback?code=SplxlOBeZQQYbYS6WxSbIA&state=af1b...&iss=https%3A%2F%2Fauth.example.com HTTP/1.1
Host: localhost:8090
The matching token request has to repeat the resource value and include the PKCE verifier:
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%3A8090%2Fcallback&
client_id=mcp-client-123&
code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk&
resource=https%3A%2F%2Fmcp.example.com%2Fmcp
That repeated resource parameter is how the authorization server knows which API or MCP server should accept the token. If you omit it when the provider expects audience-restricted tokens, you may still receive a token, but it may be valid for the wrong resource or rejected by the MCP server during audience checks.
The deploy bot skips the browser but not the resource problem. Its Client Credentials request still names resource=https://mcp.example.com/mcp and only the deployments:* scopes it needs. One broad machine token that can call every environment is convenient right up until you have to answer which deployment, tenant, or service initiated a dangerous action.
The Server Validates the Token, Then Maps It to Policy
Once the client has a token, the server’s work begins. At minimum the MCP server validates the token signature or introspection result, the issuer, expiration and not-before, the audience or resource binding, and the scopes the requested capability needs.
Local JWT validation
Many providers issue JWT access tokens signed with a key published through JWKS metadata. In that model the server fetches the key set, caches it, verifies the signature locally, and then evaluates claims. Local validation is fast, works well at scale, and keeps every request from depending on a network round trip back to the identity provider.
Remote introspection
Some providers issue opaque tokens or want revocation checked centrally. In that model the MCP server calls the provider’s introspection endpoint to ask whether the token is active and what claims it carries. That is the right choice when revocation latency matters more than request-path performance, or when the provider gives you no JWT to validate locally. The cost is that every protected request now depends on another network hop and another system’s availability. Prefer local validation, with introspection reserved for providers or token classes that require it.
A valid token is not a permission
Whichever model you use, the server still has to map claims into policy. The tickets server uses one AuthContext shape everywhere in this book:
interface AuthContext {
subject: string;
clientId: string;
tenantId: string;
scopes: string[]; // what this token delegates; a step-up can widen it
roles: string[]; // what this person is; no token can widen it
}
Scopes and roles answer different questions, so the policy keeps them apart and reports which one failed:
type Decision =
| { allow: true }
| { allow: false; missingScopes: string[] }
| { allow: false; reason: string };
function canInvokeDeleteDeployment(auth: AuthContext, deploymentTenant: string): Decision {
const missing = ["deployments:write"].filter((s) => !auth.scopes.includes(s));
if (missing.length > 0) return { allow: false, missingScopes: missing };
if (auth.tenantId !== deploymentTenant) return { allow: false, reason: "no such deployment in your tenant" };
if (!auth.roles.includes("operator")) return { allow: false, reason: "deleting deployments needs the operator role" };
return { allow: true };
}
auth.scopes is an array, so includes is an exact match. Run the same check against the raw scope string and deployments:write-preview would pass. Keep this mapping in the MCP server, not scattered through tool handlers, and not in assumptions about which provider claim happens to exist this month.
A Concrete Node Pattern
The structure is consistent across frameworks: expose metadata, require bearer tokens, validate them, and set the auth context before dispatching the MCP request. A minimal Express shape:
import express from "express";
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
const app = express();
const issuer = "https://auth.example.com";
const audience = "https://mcp.example.com/mcp";
const PRM_URL = "https://mcp.example.com/.well-known/oauth-protected-resource/mcp";
// Discover, don't hard-code: read jwks_uri from AS metadata once, at boot.
const asMeta = await (await fetch(`${issuer}/.well-known/oauth-authorization-server`)).json();
if (asMeta.issuer !== issuer) throw new Error(`AS metadata issuer mismatch: ${asMeta.issuer}`);
const jwks = createRemoteJWKSet(new URL(asMeta.jwks_uri));
// The one place that knows this provider's claim names. Unit-test it.
function mapClaims(p: JWTPayload): AuthContext {
if (typeof p.sub !== "string" || typeof p.tenant_id !== "string") {
throw new Error("token lacks sub or tenant_id");
}
return {
subject: p.sub,
clientId: String(p.client_id ?? p.azp ?? ""),
tenantId: p.tenant_id,
scopes: String(p.scope ?? "").split(" ").filter(Boolean),
roles: Array.isArray(p.roles) ? p.roles.map(String) : [],
};
}
app.get("/.well-known/oauth-protected-resource/mcp", (_req, res) => {
res.json({
resource: audience,
authorization_servers: [issuer],
scopes_supported: ["tickets:read", "tickets:write", "deployments:read"],
bearer_methods_supported: ["header"],
});
});
app.use("/mcp", express.json(), async (req, res, next) => {
const header = req.header("authorization");
if (!header?.startsWith("Bearer ")) {
res.set("WWW-Authenticate", `Bearer resource_metadata="${PRM_URL}", scope="tickets:read"`);
return res.status(401).json({ error: "invalid_token" });
}
try {
const { payload } = await jwtVerify(header.slice(7), jwks, { issuer, audience });
req.auth = mapClaims(payload);
next();
} catch {
res.set(
"WWW-Authenticate",
`Bearer resource_metadata="${PRM_URL}", scope="tickets:read", error="invalid_token", error_description="Token validation failed"`
);
return res.status(401).json({ error: "invalid_token" });
}
});
Metadata at a stable URL, a recoverable 401, issuer and audience checks, and one mapping function: the rest of the server trusts req.auth and nothing else.
The handler that joins the pieces
The policy decides; the handler turns the decision into the right protocol answer. A missing scope is something the client can fix with a step-up, so it becomes an HTTP 403. A wrong tenant or a missing role is something no new token will fix, so it becomes a tool error the model can read:
app.post("/mcp", async (req, res, next) => {
const call = req.body;
if (call?.method !== "tools/call" || call.params?.name !== "delete_deployment") return next();
const deployment = await deployments.get(call.params.arguments?.deploymentId);
const decision = canInvokeDeleteDeployment(req.auth!, deployment?.tenantId ?? "");
if (decision.allow) return next(); // the MCP transport runs the tool
if ("missingScopes" in decision) { // the token lacks authority: step-up fixes it
res.set("WWW-Authenticate",
`Bearer error="insufficient_scope", scope="${decision.missingScopes.join(" ")}", resource_metadata="${PRM_URL}"`);
return res.status(403).json({ error: "insufficient_scope" });
}
return res.json({ // the person lacks authority: a tool error, no step-up
jsonrpc: "2.0", id: call.id,
result: { isError: true, content: [{ type: "text", text: `Cannot delete deployment: ${decision.reason}.` }] },
});
});
On the wire, the step-up case looks like this:
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope", scope="deployments:write", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp"
Put every scope the current operation needs into that one challenge; challenging for one missing scope at a time forces repeated round trips. The client then runs a step-up authorization, asking for the union of the scopes it already requested and the challenged ones, and retries a bounded number of times. 401 tells the client to authenticate again. 403 tells it the token is recognized but lacks authority.
Calling Downstream APIs: Never Pass the Token Through
Sooner or later a tickets tool has to call another API as the user. link_github_issue needs to read an issue from GitHub; create_incident needs to open a page in the paging system. The server already holds a perfectly good bearer token. Do not send it.
The token’s audience is https://mcp.example.com/mcp. A downstream API that checks audience will reject it, which is the good outcome. A downstream API that does not check audience will accept it, and now every token your authorization server issues for the MCP server is also a key to that API. The downstream logs show the user calling directly, with no trace of the MCP server that made the call, and any rate limits or policy the MCP server applies are bypassed by anyone who can replay the token. The spec closes this off in one line: MCP servers must not accept or transit any other tokens.
There are two sound designs, and the choice depends on who issues the downstream API’s tokens.
The downstream API has its own authorization server (GitHub, a SaaS vendor). The MCP server is a separate OAuth client of that API. The first time a user calls link_github_issue, the tool returns an error with a link that starts GitHub’s own authorization flow; the user consents to GitHub, the MCP server stores the resulting token keyed by (auth.tenantId, auth.subject) from the validated AuthContext, and later calls use it. The key never comes from a tool argument, because the model must not be able to choose whose GitHub token is used.
The downstream API trusts the same authorization server. Use OAuth 2.0 Token Exchange (RFC 8693). The MCP server trades the incoming token for a new one bound to the downstream audience, with narrower scope:
POST /token HTTP/1.1
Host: auth.example.com
Authorization: Basic <tickets-server client credentials>
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:token-exchange&
subject_token=<the incoming access token>&
subject_token_type=urn:ietf:params:oauth:token-type:access_token&
resource=https%3A%2F%2Fpaging.example.com&
scope=incidents%3Awrite
The authorization server decides whether this exchange is allowed, and can record the MCP server as the actor in an act claim (RFC 8693 §4.1), so the paging system’s log names both the user and the server that acted for them. That is the rule of this chapter applied one hop further.
The hardest case is an MCP server that fronts a third-party API through one static OAuth client ID while letting its own MCP clients register dynamically. The spec’s security guidance describes the attack. A user authorizes once, and the third-party authorization server sets a consent cookie for the proxy’s client ID. An attacker registers a new MCP client with its own redirect_uri and sends the user a crafted authorization link. The cookie skips the third-party consent screen, the proxy completes the flow, and the MCP authorization code lands at the attacker. The server has become a confused deputy: it used its own authority for a party it never checked. The required fix is per-client consent at the MCP server, recorded per user and client_id, shown before any redirect to the third party. Match redirect_uri exactly against the registered value, and set the state cookie only after the user approves.
Token Storage and Refresh Are Client Reliability Problems
Once OAuth works for a single request, the next failure usually arrives as expiration.
An access token lasts twenty or sixty minutes, then an active session hits the wall. Plan for it and expiry becomes routine:
- store access tokens securely,
- store refresh tokens even more carefully,
- refresh before or on first unauthorized response,
- and retry once with discipline.
Desktop clients use the OS keychain; server-side clients use encrypted secret storage, never plaintext config. A Streamable HTTP client that receives 401 should read the WWW-Authenticate header, refresh or reauthorize, and retry once. The official client SDKs’ auth helpers do this; use them.
What you do not want is an infinite loop where every unauthorized request triggers another authorization attempt. One retry after refresh is reasonable. Endless retries turn a token-expiry bug into a denial-of-service bug against your own authorization server.
Long-running workflows need an explicit policy too. If a user starts an operation that launches asynchronous work, decide whether that work:
- must complete within the validity of the current token,
- can continue based on a server-side queued authorization decision,
- or must pause and ask the client to return with fresh credentials.
There is no universally correct answer. The wrong answer is to leave the behavior undefined.
Identity Providers Differ in Three Places
The same implementation questions show up with every provider. Each of the big three has one gotcha that breaks MCP servers most often:
| Provider | Gotcha | What to do |
|---|---|---|
| Microsoft Entra ID | Audience drift: the client requested a token for Microsoft Graph or another API and presents it to your server. v1 and v2 tokens also carry different issuer formats. | Register an application ID URI for the MCP server, request tokens for it, and accept only the issuer version you configured. |
| Auth0 | aud can be an array, and the value that matters is the API identifier you created. | Set the API identifier to the canonical MCP URI and require that aud contains it. |
| Okta | Only a custom authorization server issues access tokens your API can validate locally; introspection is the alternative. | Use a custom authorization server; switch to introspection when revocation latency matters more than a network hop. |
Across providers, keep provider-specific configuration at the edges. mapClaims is where a tid becomes a tenantId. Nothing else in the server should know which provider signed the token.
Failure Modes
What each of these looks like on the wire, and the curl that confirms it, is in Appendix E §Auth Fails.
Wrong audience, right issuer
The server validates signature and issuer, then accepts a token that was minted for another API because no audience or resource check exists. This is one of the easiest mistakes to make when the identity provider setup is shared across many APIs. Always bind the token to the MCP server’s canonical resource identity.
Resource metadata that does not match deployment reality
Your /.well-known/oauth-protected-resource document still advertises https://old-auth.example.com after a migration. Clients follow it faithfully and fail in ways that look random. Treat metadata as a production dependency. Test it, version it carefully, and include it in deployment verification.
Token validation without tenant enforcement
A valid token enters the system and the MCP server authorizes actions solely on scope, ignoring tenant or workspace binding. That is how you get cross-tenant data exposure even though the OAuth implementation itself looks technically correct.
Provider-specific claims treated as universal truth
Claim names differ (tid, tenant_id, custom roles). If the mapping is undocumented, a provider config change can silently break authorization. Keep it in mapClaims and cover it with tests.
The caller’s token forwarded downstream
A tool calls another API with the MCP access token because it was already in hand. The call works against a lenient API, the audit trail on the other side names the wrong caller, and a stolen MCP token now opens a second system.
Implementation Checklist
- Protected resource metadata is served at the path-form well-known URL, and
authorization_serversnames the real authorization server. - The server reads
jwks_urifrom authorization server metadata at boot and checks the metadata’sissuer. - The client validates
issin the authorization response (RFC 9207) before redeeming the code. - Every
401carriesresource_metadataand the requiredscope; every missing-scope403carriesinsufficient_scopeand all the scopes the operation needs. - Tool handlers read
req.auth, never a tool argument, for identity, tenant and roles. - Downstream calls use a separate upstream grant or RFC 8693 token exchange, keyed by the validated subject.
- Refresh retries once, and long-running work has a written token policy.
- Metadata, validation and step-up are exercised in the MCP Inspector and in automated tests.
What Comes Next
The tickets server now rejects what it should and names who acted when it accepts. Every rejection tells the client what to do next, and every acceptance names its principal. That still leaves the dangerous case: a correctly authenticated engineer, in the right tenant, with the right scopes, whose model has just read an attacker’s text. Chapter 20 starts there.