When the Agent Calls You: Authorization for an MCP Server
TL;DR
In my post on API credentials, my app was the caller. Here my code is the API: an MCP server, called by an AI app I didn’t build, for a user I have never seen. The app starts with only the server’s URL, and a 401 leads it to the login page. It identifies itself with a client ID that is a URL, so nobody registers it in advance. The token comes from the authorization code flow with PKCE, plus a resource parameter that binds it to one server and an iss check that stops a mix-up attack. The server checks the token’s audience and never forwards it. As of the MCP spec revision 2026-07-28, November 2026.
One server, callers I never met
The running example is still the invented Northstar Bank from my posts on support automation. In the earlier post, each API handed our app a credential. Now Northstar wants its employees to look up accounts from the company’s AI assistant, Claude or ChatGPT, so it puts an MCP server in front of its data: https://mcp.northstar.example.com/mcp, with tools like lookup_account and open_case. The Model Context Protocol (MCP) is the standard way for an AI app to discover and call such tools.
The sides have swapped. The advisor from the earlier post asks the assistant about Alex Carter’s locked account, and the call arrives at our server. The server doesn’t know which app is calling, or which user it acts for. And the app doesn’t know where to log in: it only has the URL someone pasted into its settings.
The running example as one map. Compared with the earlier post, the colors have swapped: the caller isn’t ours, the API is. Each section’s map highlights its part.
Every mechanism below answers one of two questions: who is this client? and where does it get a token for me? (Local MCP servers, started over standard input and output, should not use any of this: they read credentials from the environment.)
Three roles, two of them familiar
MCP authorization is OAuth 2.1, with the same three roles as in the earlier post:
- Resource server: the MCP server. It checks access tokens.
- Client: the MCP client inside the AI app, acting for the user.
- Authorization server: issues the tokens after the user logs in and agrees. Here, Northstar’s identity provider, issuer
https://login.northstar.example.com.
The MCP server never logs anyone in and never issues a token. The authorization server can be an existing system like Okta, Entra ID or Keycloak, and OpenAI’s docs strongly recommend that over writing your own. Northstar’s employees already log in there.
From a 401 to the login page
Three requests from a bare URL to the login page. Nothing here is configured in the client in advance.
The client’s first call carries no token (1). The MCP server answers 401, and the header says where to go next (2):
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.northstar.example.com/.well-known/oauth-protected-resource/mcp",
scope="accounts:read"In the earlier post, a 401 meant “get a new token”, and our app knew where. This one also says where.
The client fetches the resource_metadata document (3). This is Protected Resource Metadata (RFC 9728), which MCP servers must publish: the server says which authorization server issues its tokens (4).
{
"resource": "https://mcp.northstar.example.com/mcp",
"authorization_servers": ["https://login.northstar.example.com"],
"scopes_supported": ["accounts:read"],
"bearer_methods_supported": ["header"]
}resource is the server’s canonical URI, the one URL that names it. It comes back in the token request and in the token. authorization_servers names the authorization server by its issuer.
Then the client fetches the authorization server’s own metadata (5), defined by RFC 8414 or OpenID Connect Discovery, and gets its endpoints and what it supports (6):
{
"issuer": "https://login.northstar.example.com",
"authorization_endpoint": "https://login.northstar.example.com/oauth2/authorize",
"token_endpoint": "https://login.northstar.example.com/oauth2/token",
"jwks_uri": "https://login.northstar.example.com/oauth2/jwks",
"scopes_supported": ["accounts:read", "cases:write", "offline_access"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none", "private_key_jwt"],
"client_id_metadata_document_supported": true,
"authorization_response_iss_parameter_supported": true
}S256 is the PKCE method, which Claude and ChatGPT both require. The two flags at the end come up in the next sections: client IDs that are URLs, and an iss on every callback.
Before using the document, the client checks that the issuer in it is exactly the one it built the URL from (7). Otherwise a document from an attacker’s host could claim to be an honest server’s.
Who is this client? Client IDs that are URLs
In the earlier post, the calendar provider’s admin registered our app and handed over a client_id. Nobody does that here: Northstar can’t register every AI app in the world, and the app can’t register by hand at every server it meets.
The answer is a Client ID Metadata Document (CIMD, an IETF draft): the client_id is an HTTPS URL, and the authorization server reads the client’s description from it. For the walkthrough, an invented client:
{
"client_id": "https://assistant.example.com/oauth/client-metadata.json",
"client_name": "Example Assistant",
"redirect_uris": ["https://assistant.example.com/oauth/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"token_endpoint_auth_method": "none"
}
"none" makes it a public client, with no secret. Nobody could have handed it one, and PKCE still ties each code to the login that asked for it.
The client's identity is a document it hosts itself. The auth server reads it the first time it sees the URL.
The client starts the login with the URL as its client_id (1), and the authorization server fetches it (2, 3).
It must check that the client_id inside equals the URL (4), and that the login’s redirect_uri is one of the document’s redirect_uris (5). That stops anyone from using this client’s ID with their own callback.
It should cache the document (6).
The older way, Dynamic Client Registration (DCR, RFC 7591), has the client POST itself to a /register endpoint for a new ID. Anthropic’s docs say it makes Claude register a new client on every fresh connection. A CIMD ID is one stable identity per app, which an authorization server can allowlist or revoke. DCR is deprecated as of 2026-07-28.
In practice (November 2026): Claude uses CIMD only if the metadata has both
client_id_metadata_document_supported: trueand"none"intoken_endpoint_auth_methods_supported, otherwise it falls back to DCR. ChatGPT prefers CIMD, with the stable client IDhttps://chatgpt.com/oauth/client.jsononly if the authorization server meets theissrequirements of the mix-up section.
The authorization code flow, with two additions
From here on it is the authorization code flow of the earlier post, with state and PKCE doing the same jobs. Two things are new: the resource parameter, and the issuer the client records and later checks.
The advisor is in the loop for steps 2 and 3. The client checks two things on the callback, the auth server one, and the MCP server the token on every call.
The client makes a state and a PKCE code_verifier with its code_challenge, as before (1). New: it must also record the issuer of the authorization server it is about to send the advisor to.
Then it opens the advisor’s browser at the authorization endpoint (2), here one parameter per line, unencoded:
https://login.northstar.example.com/oauth2/authorize
?response_type=code
&client_id=https://assistant.example.com/oauth/client-metadata.json
&redirect_uri=https://assistant.example.com/oauth/callback
&scope=accounts:read
&state=Xq7pV2mL9sKw
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
&resource=https://mcp.northstar.example.com/mcpresource is a resource indicator (RFC 8707): it names the MCP server the token is for, by its canonical URI. The client must send it here and in the token request. The scope comes from the 401.
The advisor logs in and approves (3). The browser comes back with the code, the state, and one value more, iss (4):
https://assistant.example.com/oauth/callback
?code=SplxlOBeZQQYbYS6WxSbIA
&state=Xq7pV2mL9sKw
&iss=https://login.northstar.example.comThe client checks the state and compares iss with the issuer it recorded (5). The next section shows why.
It trades the code with the code_verifier and the same resource (6), sending its client_id and no secret:
POST /oauth2/token HTTP/1.1
Host: login.northstar.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https%3A%2F%2Fassistant.example.com%2Foauth%2Fcallback
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
&client_id=https%3A%2F%2Fassistant.example.com%2Foauth%2Fclient-metadata.json
&resource=https%3A%2F%2Fmcp.northstar.example.com%2FmcpThe authorization server checks the verifier (7) and returns an access token (8). As a JWT, its payload could look like this (RFC 9068):
{
"iss": "https://login.northstar.example.com",
"sub": "u-48213",
"aud": "https://mcp.northstar.example.com/mcp",
"client_id": "https://assistant.example.com/oauth/client-metadata.json",
"scope": "accounts:read",
"exp": 1794067200
}The resource came back as the audience, aud: that is how the MCP server can tell the token was minted for it. sub is the advisor, client_id the app that asked.
The client sends the token on every call (9), and the server checks it (10) before it answers (11).
What iss stops: the mix-up attack
MCP adds a setting the earlier post’s attacks didn’t have: one client connected to many servers, each with its own authorization server, and one of them malicious. That enables the mix-up attack, described in the OAuth security best current practice.
The honest server's iss on the callback doesn't match the server the client started with. The client stops before it sends anything.
The advisor adds a malicious MCP server, whose authorization server is Evil AS. The client starts a login and records “this login is with Evil AS” (1).
It sends the browser to Evil AS (2), which shows no login page and passes the same request on to the honest authorization server (3). The advisor is signed in there and approves (4), and the honest server sends a code for the advisor’s real account to the client (5).
Without an iss check, the client looks up the login by state, finds Evil AS, and sends it the code and the PKCE verifier. The attacker redeems both at the honest server. state matched, because it is the client’s own value, and PKCE didn’t help, because the client handed over the verifier itself.
With RFC 9207, the honest server put its own issuer in the callback. The client sees it isn’t Evil AS and stops (6), before the code goes anywhere.
The comparison is an exact string match, with no normalizing. As of 2026-07-28, authorization servers should send iss, and the spec expects a later revision to make it a must. One consequence I see: a CIMD client ID is the same URL everywhere, so the attacker doesn’t even need to learn one per server, which makes the check more important.
Checking the token on every request
Every request carries the token in the Authorization header, never in the URL, and the server must check that the token was issued for it. The verification is the one from the earlier post; what changed is the audience:
import jwt # PyJWT
ISSUER = "https://login.northstar.example.com"
RESOURCE = "https://mcp.northstar.example.com/mcp" # canonical URI, no trailing slash
jwks = jwt.PyJWKClient(f"{ISSUER}/oauth2/jwks")
def verify(token: str) -> dict:
key = jwks.get_signing_key_from_jwt(token)
return jwt.decode(
token,
key.key,
algorithms=["RS256"], # never trust the token's own "alg"
audience=RESOURCE, # minted for this MCP server, not the CRM
issuer=ISSUER,
leeway=30,
options={"require": ["exp", "iss", "aud", "sub"]},
)
def missing_scopes(claims: dict, needed: set[str]) -> set[str]:
granted = set(claims.get("scope", "").split())
return needed - granted # non-empty: answer 403 insufficient_scope with these scopes
A token Northstar’s identity provider minted for another API fails the audience check, even with a valid signature. In the official Python SDK, the audience check sits behind AuthSettings(validate_token_resource=True). In version 2.3.0, which I checked, leaving it unset only logs a warning and skips the check.
A 401 means no token, or an invalid or expired one. A 403 means the token is fine but lacks a scope, and here the story differs from the earlier post. There, retrying a 403 was pointless: the auth server grants what the client is registered for. Here the advisor can consent to more, and the header says what to ask for.
A missing scope, fixed: the client asks the advisor for more and keeps what it had.
The advisor asks the assistant to open a case for Alex, and the client calls open_case (1). The token only has accounts:read (2). The server should answer with the scope it needs (3):
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope",
scope="cases:write",
resource_metadata="https://mcp.northstar.example.com/.well-known/oauth-protected-resource/mcp"The client asks for the union of the old and the new scopes, accounts:read cases:write (4), so the token doesn’t lose what it could already do.
It runs the authorization again (5), the advisor approves the new scope (6), and the client gets a new token (7). The retry opens the case (8, 9). This is step-up authorization.
All of this serves one decision the server makes on every call: whose data may this touch? sub says which employee asks, the scopes what they agreed to. The rest is the server’s own rule, per tool: the advisor may see Alex’s account because Alex is their customer, not because the token is valid.
Behind the server: two tokens, two audiences
lookup_account needs the CRM, and it is tempting to forward the token that just arrived. The spec forbids this token passthrough: MCP servers must not accept or pass on tokens that weren’t issued for them. The MCP security best practices give the reasons: the downstream API’s rate limits and monitoring are bypassed, the audit trail is lost, and a token several services accept lets an attacker who compromises one reach the others.
So Northstar’s MCP server calls the CRM as our app did in the earlier post, with its own client credentials token.
The advisor's token stops at the MCP server. The CRM sees only the MCP server's own token.
The client calls with the advisor’s token, audience the MCP server (1). The server checks it and reads sub and the scopes (2).
It gets its own token for the CRM with its client ID and secret (3, 4), calls the CRM with it (5), and gets the account (6).
It returns only what this advisor may see (7). The CRM trusts the MCP server; the MCP server decides about the advisor.
If a downstream API must act as the user, the MCP server gets a new token for it, through its own OAuth flow there or Token Exchange. It never reuses the one it received.
When the company decides, not the user
Everything so far asks each employee, for each MCP server, on a consent screen. The Enterprise-Managed Authorization extension moves that decision to the company’s identity provider: admins approve which apps may reach which servers for which groups, and users sign in once. It matters most where the server belongs to another company, such as a vendor’s MCP server used by Northstar’s employees.
The company’s IdP checks its policy and issues an ID-JAG (Identity Assertion JWT Authorization Grant). The MCP server’s own authorization server still issues the access token.
Claude supports it on Team and Enterprise plans; I couldn’t confirm ChatGPT support. A follow-up post goes through it in detail.
Checklist
For an MCP server and its authorization server, before pointing Claude or ChatGPT at it:
401, not200or a tool error, on a call without a token, withresource_metadatainWWW-Authenticate. Claude ignores the header on a200.- Protected Resource Metadata with
resourceexactly the URL users enter, path included. Claude uses only the first entry ofauthorization_servers. - Authorization server reachable from the AI app’s network: Claude calls from
160.79.104.0/21, so check the firewall in front of the identity provider. S256incode_challenge_methods_supported.- CIMD advertised, with
noneintoken_endpoint_auth_methods_supported. isson every authorization response, success and error, andauthorization_response_iss_parameter_supported: true. ChatGPT needs both for its stable client ID.resourcecopied intoaud, andaudchecked on every request.- Scopes checked per tool, with
403 insufficient_scopenaming all needed scopes in one challenge. - Refresh tokens rotated for public clients, with
invalid_grantonce they are dead. - The incoming token never forwarded downstream.