Chapter 1 of 8 · 17 min read
Discovering the authorization server
On this page
Remote MCP servers authenticate with OAuth, a name short for open authorization: a protocol for letting a person grant a piece of software limited access to their account at some service without giving that software their password. Every "Sign in with GitHub" button and every "Connect your Google Drive" screen is running it — the user is sent to the provider, logs in there, approves a named list of permissions called scopes, and the application receives a time-limited access token in place of a password. Remote MCP servers use the current revision, OAuth 2.1, which folds a decade of security errata into the 2012 original.
OAuth separates the roles, and the names matter for everything that follows. The authorization server authenticates the user, renders the consent screen and mints tokens; it is usually the organisation's identity provider, or IdP, such as Okta, Entra ID or Auth0. The resource server is the thing being protected, which here is the remote MCP server, and it does nothing but inspect tokens and refuse the ones that were not meant for it. The client is the software acting on the user's behalf, which in this topic is the MCP client inside the agent host. The credential passing between them is a bearer token, sent as Authorization: Bearer <token> on every request, and it works for whoever is holding it — which is why much of this topic is about limiting where a stolen one can be spent. Discovery is how a client with none of those values learns all of them.
The 401 is a document request, not a rejection
A client that opens the transport and posts initialize with no credential is answered before the JSON-RPC layer sees the message:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.northwind-analytics.com/.well-known/oauth-protected-resource/mcp"
Content-Type: application/json
Content-Length: 78
{"error":"unauthorized","error_description":"Authorization required for this resource"}That header is the entire onboarding process. It names the scheme and it names a URL that describes what this resource is and who issues tokens for it. The protocol exists in this shape because the two obvious alternatives fail as soon as more than one system is involved. Handing an application your password gives it everything you can do, for as long as the password lasts, and withdrawing it means changing that password everywhere. An API key — one long-lived string, identical for every caller, pasted into a config file — improves on that, and it still says nothing about which user is acting, cannot be narrowed to a subset of their permissions, and is withdrawn only by rotating it for everybody at once.
Why the challenge has to carry that header, rather than leaving the client to fetch a path everyone agrees on, is a short argument from what the client actually holds. Its entire input is a URL somebody typed into a configuration file. It does not know the resource's canonical identifier, which may differ from that URL. It does not know which of several resources on the same origin it is addressing. And it cannot ask, because asking takes a credential and obtaining a credential takes knowing where to ask. The only channel carrying information from the server to a client in that state is the response to the unauthenticated request, so anything the client needs has to travel in it. A well-known path guessed from the URL exists as a fallback for exactly this reason rather than as a substitute: it works when the guess happens to be right, and the header is what makes guessing unnecessary.
Fetching the URL in resource_metadata returns the server's protected resource metadata, defined by RFC 9728. It is a small JSON document, unauthenticated, cacheable, and served at a well-known path derived from the resource URL — note the /mcp suffix on the well-known path above, which is how a resource that lives at a path rather than at a bare host keeps its metadata distinct from any other resource on the same origin.
{
"resource": "https://mcp.northwind-analytics.com/mcp",
"authorization_servers": [
"https://auth.northwind-analytics.com"
],
"scopes_supported": ["reports:read", "reports:write", "accounts:read"],
"bearer_methods_supported": ["header"],
"resource_documentation": "https://docs.northwind-analytics.com/mcp"
}Two fields carry the weight. resource is the canonical identifier for this server, and every token the client eventually obtains has to be minted for exactly that string — chapter 3, on what an aud claim binds, is about what happens when it is not. authorization_servers names who to go and talk to, and it is a different host from the MCP server itself, which is the structural point of the whole arrangement: the two roles sit on separate systems, so the MCP server never holds a user password, never runs a consent screen and never issues a token.
This split arrived relatively recently. Early drafts of the MCP authorization spec had the MCP server acting as its own authorization server, implementing the token endpoint and the authorization endpoint itself, which meant every team shipping an MCP server was also shipping an OAuth authorization server — a component with a long history of subtle, exploitable mistakes. The 2025-06-18 revision replaced that with the resource-server split and RFC 9728 discovery, and the 2025-11-25 specification keeps it. The practical consequence is that most MCP servers now delegate to something that already exists in the organisation: Okta, Entra ID, Auth0, Keycloak, or the company's own IdP.
The next fetch follows authorization_servers[0] to its own metadata, per RFC 8414:
{
"issuer": "https://auth.northwind-analytics.com",
"authorization_endpoint": "https://auth.northwind-analytics.com/oauth2/authorize",
"token_endpoint": "https://auth.northwind-analytics.com/oauth2/token",
"registration_endpoint": "https://auth.northwind-analytics.com/oauth2/register",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none", "client_secret_basic"]
}A client should refuse to continue if code_challenge_methods_supported does not include S256. An authorization server that cannot do PKCE with SHA-256 is one whose codes are interceptable, and the specification makes the check a MUST rather than a preference. Some clients also pin the discovery chain: the issuer value here must match the URL that was fetched, and the authorization_servers entry must have come from metadata served by the resource the client actually intends to call, so that a compromised or malicious server cannot redirect the flow to an authorization server of its choosing without the user seeing an unfamiliar domain on the consent screen.
Real deployments are less tidy than the two documents above, and a client that only handles the tidy case will fail against perhaps a third of the servers it meets. Some servers return a bare WWW-Authenticate: Bearer with no resource_metadata parameter, either because they predate RFC 9728 or because a proxy stripped the parameter; the fallback is to try the well-known path derived from the resource URL directly, both the path-suffixed form and the bare-origin form, before giving up. Some list two or three entries in authorization_servers, which happens when a server accepts tokens from a customer IdP as well as its own, and the client has to pick one — in practice the first entry, unless the user has an existing registration against another. And some authorization servers publish only OpenID Connect discovery at /.well-known/openid-configuration rather than the RFC 8414 path, so a client that tries exactly one URL and treats a 404 as fatal will report "this server does not support OAuth" about a server that does.
Registering a client nobody has ever met
The client now knows where to send an authorization request, and it still has no client id. For a first-party integration that is settled out of band: an administrator creates the client in the IdP console and the client id ships in the application. For an agent host connecting to arbitrary third-party servers, out-of-band registration does not scale, which is why the registration_endpoint is there. Dynamic client registration (RFC 7591) is a single unauthenticated POST:
curl -sS -X POST https://auth.northwind-analytics.com/oauth2/register \
-H 'Content-Type: application/json' \
-d '{
"client_name": "Northwind Desktop Agent",
"redirect_uris": ["http://127.0.0.1:33418/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
"scope": "reports:read accounts:read"
}'{
"client_id": "c_9f31ab72e0c4",
"client_id_issued_at": 1775692800,
"redirect_uris": ["http://127.0.0.1:33418/callback"],
"token_endpoint_auth_method": "none"
}token_endpoint_auth_method: "none" says this is a public client, which is what a desktop agent or a CLI is: the binary is on the user's machine, so any secret compiled into it is not a secret. Public clients rely on PKCE rather than a client secret to prove that the entity redeeming the code is the one that started the flow. A server-side MCP client running in your own infrastructure is a confidential client and should register with client_secret_basic, hold the secret in a secrets manager, and still use PKCE.
Registration is worth persisting. The client id is stable and reusable, so store it keyed by the authorization server's issuer, and re-register only when the server rejects the id with invalid_client. Clients that re-register on every launch create a new orphan client record on the IdP for every process start, which is the sort of thing that shows up as thirty thousand registered clients in an Okta tenant six weeks after launch and gets your integration switched off.
The round trip that produces a token
With an issuer, endpoints, and a client id, the grant itself is the ordinary OAuth 2.1 authorization-code flow with one MCP-specific addition. The client builds an authorization URL, opens it in the user's system browser, and waits on a loopback listener — a short-lived HTTP server bound to 127.0.0.1 on the user's own machine, which is where the browser will deliver the result:
https://auth.northwind-analytics.com/oauth2/authorize
?response_type=code
&client_id=c_9f31ab72e0c4
&redirect_uri=http%3A%2F%2F127.0.0.1%3A33418%2Fcallback
&scope=reports%3Aread+accounts%3Aread
&state=v7Qm2s0Lk9dRr1Xw
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
&resource=https%3A%2F%2Fmcp.northwind-analytics.com%2Fmcpstate and code_challenge are the subject of chapter 2, on what each control binds, and resource is chapter 3's, so take them on trust for a moment. The user authenticates to Northwind in their own browser, sees a consent screen naming the client and the scopes, approves, and the browser is redirected to the loopback URI with ?code=...&state=.... The client checks the returned state against what it sent, and only then exchanges the code:
curl -sS -X POST https://auth.northwind-analytics.com/oauth2/token \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode grant_type=authorization_code \
--data-urlencode code=AUTHCODE_from_callback \
--data-urlencode redirect_uri=http://127.0.0.1:33418/callback \
--data-urlencode client_id=c_9f31ab72e0c4 \
--data-urlencode code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk \
--data-urlencode resource=https://mcp.northwind-analytics.com/mcp{
"access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjhhM2QifQ...",
"token_type": "Bearer",
"expires_in": 900,
"refresh_token": "rt_2f8c1d9b4a7e6350",
"scope": "reports:read accounts:read"
}Nine hundred seconds is a realistic access-token lifetime for this kind of integration, and it is short enough that refresh is not an edge case. A session in which an agent works for forty minutes crosses the expiry twice. The refresh token typically lives days to weeks and, for public clients, should be rotated on every use: each refresh returns a new refresh token, the old one is invalidated, and replay of a stolen refresh token triggers the authorization server to revoke the whole chain.
That claim is arithmetic worth performing, because it decides whether refresh is an everyday code path or a branch nobody has run. Nine hundred seconds is fifteen minutes. A session in which an agent works for forty minutes passes the fifteen-minute mark and the thirty-minute mark; the next expiry would fall at forty-five, which is outside the session. Exactly two, so the client makes that transition twice inside one conversation. Anything long enough to be called a task crosses it at least once, and at a lifetime of fifteen minutes there is no such thing as a session short enough to avoid it — which is the difference between a refresh path exercised in production every day and one that first executes in front of a customer.
The full path, from the first unauthenticated call to the first successful tool invocation:
The failure paths a static token never has
Treating the access token as configuration is not merely inelegant; it deletes the code that handles the four ways this goes wrong in production, and each of them has a different correct response.
| Signal | Where it appears | What it means | Correct client action |
|---|---|---|---|
expires_in elapsed | Client's own clock | Access token stale | Refresh before the call, do not wait for a 401 |
401 + invalid_token mid-session | MCP server response | Token expired, revoked, or wrong audience | Refresh once, retry once; if it repeats, restart discovery |
400 + invalid_grant | Token endpoint, on refresh | Refresh token expired, rotated, or revoked | Discard tokens, run the full authorization flow again |
403 + insufficient_scope | MCP server response | Token valid, grant too narrow | New authorization request with the required scope — never a refresh |
The distinction in the last two rows is where most clients are wrong, and chapter 8, on telling those two statuses apart from a trace, takes it apart properly. What matters here is that a refresh is not a general-purpose repair. It reissues what the existing grant already covers, so it fixes expiry and nothing else.
Those four rows are a partition over two questions, and seeing the axes is what makes them memorable rather than a list to look up. The first question is which credential has gone wrong: the access token in the first two rows, the refresh token in the third, and neither in the fourth, where both credentials are sound and the grant behind them is too narrow. The second question is who noticed: your own clock in the first row, the resource server in the second and fourth, the authorization server in the third. Each combination that arises in practice appears once, and none appears twice. That is the useful part, because it means a client handling four cases handles all of them, and a client treating every failure as one case is not being terse — it is missing three.
There is also a rule about how many times to try. A client that refreshes on every 401 and retries indefinitely will, against a server whose audience configuration is wrong, generate a refresh and a retry for every tool call the agent makes — which in an agent loop is easily 200 requests per minute against the token endpoint, and gets the client rate-limited or blocked. One refresh, one retry, then surface the error and stop.
The multiplication in that figure is the part worth seeing, because it is why the rule is "once" and not "a few times". A single failing tool call produces one refresh and one retry. An agent loop issues tool calls continuously, so the traffic reaching the token endpoint is the refresh count per call multiplied by the call rate rather than added to it. Retrying without a bound raises the multiplier to however many attempts the loop makes before giving up, and the underlying condition — a misconfigured audience, say — is identical on every attempt, so none of them can succeed. The volume is therefore proportional to how hard the client tries and independent of whether trying can help, which is the signature of a retry policy pointed at a permanent failure.
Discovery cost, and what the client persists
A cold connection to a server the client has never seen runs four HTTP round trips before the browser opens: the 401, the protected resource metadata, the authorization server metadata, and registration. Against a well-provisioned IdP each is 40 to 120 ms, so the discovery chain costs roughly a third of a second, and then the flow stalls on the user, who takes somewhere between eight seconds and two minutes to find the browser window, log in and read a consent screen. That human interval is why none of this belongs on the hot path of a tool call. A client that discovers lazily — first tool call triggers a 401, which triggers discovery, which opens a browser mid-task — produces an agent that appears to hang. Connecting at the point the user adds the server, and surfacing the consent step as an explicit action, costs the same milliseconds and puts them where the user is already waiting.
Two routes reach that third of a second and they agree, which is what makes it safe to plan against. Multiplying gives the bracket: four round trips at forty milliseconds each is a hundred and sixty milliseconds, four at a hundred and twenty is four hundred and eighty, so the chain lands somewhere between a sixth of a second and half a second, and a third sits in the middle of that bracket. The second route is to weigh it against what it is competing with, which is the human step at eight seconds even when the user is fast. The slow end of the machine bracket is more than an order of magnitude below the fast end of the human one, so the discovery chain is not what makes a cold connection feel slow, and time spent shortening it is time spent on the smaller of two terms.
Everything except the access token is durable and should be persisted. The metadata documents carry cache headers and change on the order of months, the client id is stable for the life of the installation, and the refresh token is the thing that makes the browser step a once-per-fortnight event rather than a once-per-session one. A workable storage shape keys on the pair that actually identifies a grant:
type StoredGrant = {
issuer: string; // authorization server, from discovery
resource: string; // canonical MCP server identifier
clientId: string; // from registration, reused across launches
accessToken: string; // short-lived, refreshed in place
expiresAt: number; // epoch ms, computed from expires_in at receipt
refreshToken: string; // rotated on every use for public clients
scope: string; // what the user actually granted, not what was asked
};The scope field earns its place because the authorization server is entitled to grant less than was requested, and it will: a user who declines the write checkbox on a consent screen produces a token response with scope: "reports:read" when the request asked for reports:read reports:write. A client that assumes it received what it asked for will call a write tool, get a 403, and have no idea why. Reading the granted scope off the token response and comparing it against what the server says each tool requires lets the client tell the user which tools are unavailable before they are invoked.
The belief underneath a client that skips that comparison is that a request and a grant are the same document, and it is a reasonable belief because nearly every other call a client makes does behave that way: send parameters, and if the call succeeds the parameters were honoured. An authorization request is not that shape. It is a proposal, and the consent screen is precisely the place a person is invited to reduce it, so "succeeded" and "was granted in full" are different outcomes — which the token response distinguishes, in a field most clients never read. Comparing the two strings is one line of code, and it moves a class of failure from a 403 arriving in the middle of a task to a sentence the agent can say before the task starts.
Where these values sit is a platform question rather than an OAuth one. A desktop client puts them in the OS keychain, where they are bound to the user account and encrypted at rest; a server-side client puts them in a database column encrypted with a key from a secrets manager, one row per (end user, issuer, resource). Neither belongs in a plaintext JSON file next to the config, which is where a surprising number of first implementations put them because the config file was already there.
Discovery answers where, not who
Discovery stops at "where do I authenticate". It does not tell the client who the user is; an access token in this flow is an authorization artifact, and if the client needs the user's identity it wants an OIDC ID token or a userinfo call, which is a separate request against a separate concern.
It also does not apply to local servers. A stdio server launched as a subprocess, of the kind covered in topic 6.11, on consuming servers, inherits the parent process environment and its credentials, and there is no HTTP layer for a 401 to travel over. The OAuth machinery in this topic is for remote servers reached over HTTP, and wiring a browser-based authorization flow into a local subprocess integration is work spent on a boundary that is not there.
Finally, none of this is a session. Bearer tokens are stateless from the resource server's point of view, and there is no server-side login the client can rely on. Every request carries its own proof, and the MCP session id that the streamable HTTP transport assigns is a transport concern with no authority of its own — a server that treats "this session id was authenticated once" as licence to skip token validation on later requests has built a session cookie with none of a session cookie's protections.
A URL is therefore a complete integration, provided the client can read a challenge. The 401 names the metadata, the metadata names the resource identifier and the authorization server, that server's metadata names the endpoints and the PKCE methods, and registration supplies a client id where none was arranged in advance. The user supplies consent, once, in their own browser; the client stores everything except the access token, which is the one value designed to be short-lived and replaced.
The authorization request in this chapter carried three parameters that were passed over: state, code_challenge, and redirect_uri. Each of them binds a different thing, they are routinely conflated, and knowing precisely which attack each one stops is what makes it possible, later in this topic, to recognise an attack where all three are satisfied and the flow is still compromised.
MCP auth — read the current version at https://daviddominguez.dev/learn/en/mcp-auth/discovering-the-authorization-server