Skip to content
FoxAuth

Blog

Your MCP server must check who a token is for

MCP authorization in plain terms: what your MCP server must publish, which token field must name it, and two questions the spec leaves to you.

By FoxAuth ·

The token was issued for the internal wiki. It opened every tool on the MCP server.

The MCP server did what most first attempts do. It downloaded the public keys of the identity provider, the service that issues the tokens. It checked the token’s signature and expiry time. It read the subject, the user the token names, and treated the request as authenticated. Every one of those checks passed, because the token was genuine. It was a perfectly good token for the wiki. But the MCP server never asked who the token was for. So anyone holding any token from that provider, issued for anything at all, could list the tools and call them, acting as whoever the token named.

The MCP authorization specification exists to prevent exactly this bug. Once you see the bug, the rest of the specification stops looking like a new protocol. An MCP server is an ordinary OAuth 2.1 resource server: a server that accepts tokens and, in return, lets the caller use what it holds (here, its tools). What the specification adds is the discovery steps, written down: how a client that knows nothing but the server’s URL finds its way to a token. I have spent a fair amount of time on the other side, building the authorization server, the server that issues the tokens. My summary is this: MCP authorization is three documents and one claim (a claim is a field inside the token). Almost every real problem is in that one claim.

From one URL to a token: three documents and one claim

The agent host is the program the AI agent runs in; in OAuth terms it is the client. It knows one thing: the URL of your MCP server. From that URL it has to learn three things: which authorization server protects you, how to talk to that server, and how to identify itself to it. Then it has to come back with a token that is for you and nobody else. Documents answer the first three questions. The last part is a claim in the token.

How an agent host gets a token for an MCP server: a 401 that points to the server’s metadata, that metadata, the authorization server’s metadata, an authorization request and a token request that both name the MCP server in the resource parameter, and a token the MCP server checks was issued for itAgent host (client)MCP serverAuthorization server1 POST /mcp, no token2 401 WWW-Authenticate: Bearerresource_metadata=“…”, scope=“mcp:tools-basic”3 GET the protected resource metadata (RFC 9728)resource, authorization_servers, scopes_supported4 GET the authorization server metadata (RFC 8414 or OIDC discovery)issuer, endpoints, code_challenge_methods_supported, client_id_metadata_document_supported5 /authorize client_id=host.example/client.json resource=mcp.example.com/mcpPKCE; the server fetches client.json for the name and redirect targets; the user consents6 /token code + code_verifier + the same resource7 access token with aud = mcp.example.com/mcp8 POST /mcp Authorization: Bearer …9 200, after checking signature, iss, exp and aud

Step 2 is the one an ordinary resource server would not already do. The specification makes it a MUST. It does the same for the metadata document that step 2 points to (step 3) and for the audience check (step 9). The audience is the token’s aud claim: it names the server the token was issued for. Step 2 itself is two lines. When a request arrives with no token, or with a token you reject, you answer 401 and say where your metadata is. This answer is called a challenge: in its WWW-Authenticate header the server tells the client how to get access:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
scope="mcp:tools-basic"

The metadata is a JSON document defined by RFC 9728, called protected resource metadata. In it, the MCP server says which authorization server protects it:

{
"resource": "https://mcp.example.com/mcp",
"authorization_servers": ["https://auth.example.com/acme"],
"scopes_supported": ["mcp:tools-basic"],
"bearer_methods_supported": ["header"]
}

From here on, the work belongs to the authorization server and the client. The client takes the issuer from the document. The issuer is the authorization server’s name, written as a URL; every token it signs carries that URL as iss. With that URL the client fetches the authorization server’s own metadata. There are two standard (well-known) addresses for it: one defined by RFC 8414 and one by OpenID Connect Discovery. The client has to try both, because the specification only requires the server to publish one. Then the client runs an ordinary authorization code flow with PKCE: it gets a code through the browser and exchanges it for a token, and PKCE stops anyone who intercepts the code from using it. In both the authorization request and the token request, the client sends one more parameter. That parameter has had an RFC since 2020, and in practice it is still anything but ordinary.

The resource parameter makes a token good for your server only

The parameter is resource, from RFC 8707 (Resource Indicators). With it, the client says which server it wants the token for. The MCP specification makes it mandatory in both requests. It is what turns “a token from the provider” into “a token for this MCP server”. The authorization server puts that URI into the token as its audience (aud). The MCP server refuses any token whose audience is not exactly its own identifier. The wiki token would have failed that check, and nothing else would have stopped it.

Here is the token the authorization server returns for the flow above, decoded:

{
"iss": "https://auth.example.com/acme",
"aud": "https://mcp.example.com/mcp",
"sub": "248289761001",
"client_id": "https://host.example/client.json",
"scope": "mcp:tools-basic",
"iat": 1759226400,
"exp": 1759227300,
"jti": "o1S6Qw3bH9c_UZL0"
}

On the MCP server’s side, checking this token comes down to four checks. You have to state two of them yourself, because a JWT library cannot guess them:

import { createRemoteJWKSet, jwtVerify } from 'jose';
const keys = createRemoteJWKSet(new URL('https://auth.example.com/acme/jwks'));
export async function principalFor(authorization) {
const token = authorization?.replace(/^Bearer /i, '');
if (!token) return null;
const { payload } = await jwtVerify(token, keys, {
issuer: 'https://auth.example.com/acme',
audience: 'https://mcp.example.com/mcp'
});
return { subject: payload.sub, scopes: (payload.scope ?? '').split(' ') };
}

The library checks the signature and the expiry (exp) on its own. issuer and audience are yours to supply, and audience is the one the first attempt left out. Any library in any language does the same four checks. The code above is, in effect, the checklist.

What took me longer to appreciate is that the same string now appears in three places. Three different parties write it, and it has to be identical in all of them. The MCP server publishes it in its metadata as resource. The operator of the authorization server declares it, that is, registers it as the identifier of a protected resource. The client sends it as resource, and the token carries it as aud. The comparison is exact, after canonicalization: each side’s URL is first rewritten into one standard spelling (more on that below). It is exact on purpose. There is no prefix match, and no “a token for the parent path is good for the child path”. That is precisely how one integration’s token ends up working at another integration.

One identifier in three places, written by three parties: the resource field in the MCP server’s metadata, the operator’s declaration on the authorization server, and the resource parameter the client sends, which becomes the token’s aud. The issuer links the metadata’s authorization_servers to the token’s iss in the same wayMCP server publishesresource: mcp.example.com/mcpauthorization_servers: [acme issuer]scopes_supported: [mcp:tools-basic]Operator declaresidentifier: mcp.example.com/mcpin project acme, whose issuer is /acmescopes: [mcp:tools-basic]Client sendsresource=mcp.example.com/mcpto /acme/authorize and /acme/tokenscope from the challengeThe tokenaud: mcp.example.com/mcp iss: acme issuerscope: what was asked ∩ what was declaredone string, compared exactly after canonicalization, in every place

Canonicalization is where our first version had a bug. We found it by reasoning about the code; the tests did not catch it. The specification says clients should write the URL without a trailing slash, and that servers should accept a scheme or host in uppercase. So we canonicalize both the declaration and the incoming request through one function. That function takes an option for a resource whose trailing slash is meaningful. Here is the bug. The declaration was stored with its slash kept. But when comparing, we canonicalized the request with the default options. So the slash was stripped from one side only. As a result, a resource that declared its slash significant collapsed into its twin without the slash: two different resources were treated as one. Both sides have to go through the same function with the same options. If that sounds obvious, it was obvious to me too, afterwards.

One more thing about the audience. Every token is bound to exactly one declared resource: an API or MCP server that an administrator has registered as a protected resource. Because of that, declaring a resource can safely be data rather than code. In our server, declaring an MCP server means filling in a form in the console: an identifier, a name to show on the consent screen, and the scopes the server recognises. A scope is a named permission that a token can carry, such as mcp:tools-basic. Nothing is restarted. The declaration is read on every token request, with no cache in front of it. The reason: once a declaration is deleted, tokens for it must stop on the very next request, not after some cached copy expires. We had already learned that lesson once, when a client cache served an out-of-date client to the admin console. We did not need to learn it again.

An agent identifies itself with a document URL: convenient, and risky

The agent host has your MCP server’s URL and nothing else. There is nowhere for you to paste a client identifier (client_id) into it. The specification’s first answer used to be dynamic client registration. The client POSTs a description of itself and gets a client_id back. The authorization server keeps one record for every host that has ever connected. The current revision of the specification marks this as deprecated. In its place it puts Client ID Metadata Documents first. I think that is the right call. It is also the part of the specification most worth being nervous about.

The idea: the client_id is itself an HTTPS URL. At that URL sits a JSON document that describes the client, its metadata document:

{
"client_id": "https://host.example/client.json",
"client_name": "Example Agent Host",
"redirect_uris": ["http://127.0.0.1:52123/callback", "https://host.example/oauth/callback"],
"token_endpoint_auth_method": "none",
"grant_types": ["authorization_code", "refresh_token"]
}

The authorization server fetches this document when it needs it, checks it and stores nothing. There is no registration endpoint, and no table of clients that grows by one row for every laptop. The client name on the consent screen comes from the document. The client’s operator controls that document, and it sits on a domain the operator demonstrably holds.

The cost is that a stranger, with no account and no credentials, can now make your authorization server send an HTTP request to any URL they choose. Every defence against that has to live on the server. Ours is one module that deals with the network and nothing else. It refuses private, loopback and link-local addresses. A loopback address is the machine’s own address, such as 127.0.0.1; a link-local address works only inside the local network segment. The module compares addresses as numbers, not as text. The reason is that the URL parser will happily rewrite an IPv4-mapped IPv6 address (an IPv4 address written in IPv6 form) into a form that a text check misses. The module follows redirects by hand and re-checks every hop. It stops reading after five kilobytes, and it has a time limit. The feature is off by default, and the reason is exactly that first sentence about the stranger.

Once the server has the document, the checks are short. Each one blocks a specific trick. The client_id inside the document must equal the document’s URL byte for byte, so hosting a document that names somebody else’s identifier gets you nothing. redirect_uris, the addresses the browser may be sent back to after sign-in, must be present and non-empty. A symmetric client secret (a password shared by the client and the server) is refused, because a secret in a document anyone can read is not a secret. And if the client authenticates with keys, the document must say where its keys are.

The part I would call genuinely unresolved is loopback redirects. A native agent host is an app on the user’s own computer. It waits for the sign-in callback on 127.0.0.1, on some port, so its document lists a loopback redirect. The document proves that whoever wrote it controls the domain. It cannot prove which local program is listening on that port. An attacker on the same machine can present the real host’s client_id and listen on a port of their own. The user then sees the real host’s name on the consent screen. The specification knows this, says the server should warn, and stops there. For an ordinary resource we carry that risk the way everyone does.

There is one place where such a client can get administrative rights: this server’s own MCP control plane, the MCP endpoint through which an agent manages the server. There we turned the warning into a hard stop. A document that offers only loopback redirects cannot be permitted until an administrator accepts a written statement of what such a document does not prove.

Whose users an agent signs in when several teams share the server

Here is a question the specification does not ask. It assumes that one authorization server protects one MCP server for one group of users. An authorization server stops looking like that once more than one team uses it.

Suppose the server hosts several tenants. A tenant is a team that shares the server but has its own users. Now an agent host arrives, identified by a metadata document. It belongs to no tenant; it is just a URL. Whose users does it sign in? The answer we settled on: the resource decides. An administrator of a tenant declared that MCP server in one of their projects. So a request that names that resource signs in that project’s users. It is worth saying precisely why this is safe. The client did not link this resource to this project; an administrator did, in a project they own. So the resource parameter can only choose among declarations an operator has made. It cannot create a new one.

More precisely, it was safe against attackers without a console account. Anyone with an account could declare anything. Identifiers were unique across the whole instance, and whoever declared one first kept it. So a hostile tenant could declare somebody else’s MCP server. The real owner would then run into a conflict every time they tried to declare it, for good. And sign-ins for that MCP server would go to the hostile tenant’s bucket. A bucket is this server’s name for a set of end users with its own sign-in rules. Once a bucket has an address of its own, it is also its own issuer.

The first fix made every declaration prove itself. The server fetched the resource’s own metadata and checked that it named this issuer. It is the natural fix. But it also refused every MCP server whose metadata the authorization server could not fetch: one on an internal network, one on localhost during development, one not deployed yet. It lasted a day.

Two tenants on one authorization server, each its own issuer, both declare the same MCP server identifier. A request at one tenant’s issuer sees only that tenant’s declaration. At the shared root issuer, only a super administrator can declareissuer auth.example.com/acmeacme’s users, acme’s keysdeclares mcp.example.com/mcpany acme admin, no fetch, no proofissuer auth.example.com/globexglobex’s users, globex’s keysdeclares mcp.example.com/mcp tooa claim on its own namespace onlyroot issuer auth.example.comshared by tenants with no addressdeclarations: super administrator onlymetadata names the issuer, not a tenant/acme/authorize?resource=…/mcpresolves acme’s declarationan acme user; globex’s is invisible/globex/authorize?resource=…/mcpresolves globex’s declarationsame identifier, a different token/authorize?resource=…/mcpinvalid_target unless declared at the root

What replaced it is namespaces, and it is the arrangement I would now recommend to anyone building this. A namespace here is the set of declarations that belong to one issuer. A tenant with an address of its own is its own issuer, with its own signing keys. A resource identifier only has to be unique within one issuer. Two tenants may declare the same MCP server, and neither ever sees the other’s declaration. That is because every lookup reads only the namespace of the address the request arrived at. A declaration in your own namespace needs no proof, so the server fetches nothing: such a declaration can only be a statement about your own users.

Only one namespace is genuinely shared: the root issuer. Every tenant without an address of its own is served there. The root accepts declarations from a super administrator only. The reason: at the root, a resource’s metadata can say it trusts the issuer, but it cannot say which of those tenants it belongs to. So no proof can stop squatting there, that is, a tenant taking somebody else’s identifier first. That is why only a super administrator may declare at the root.

That left three more holes in the same place, around declared resources. The first two have the same shape: “a caller forgot to pass the resource”. Both are worth knowing, because this design makes exactly this kind of mistake possible.

The first: the account resolver (the code that finds the user who is signing in) looked for the user in a different tenant than the sign-in step had used. The sign-in step passed the resource, and the resolver did not. The symptom was a 500 error on the consent screen instead of a refusal.

The second: password sign-in, password reset and registration chose their policy from the client alone. Take a tenant whose users may only sign in through an upstream provider, that is, an external identity provider. Through a declared resource, someone could still reach that tenant with a password.

We closed both by making the resource a required argument, so code that leaves it out no longer type-checks. If you take one engineering rule from this section, take this one: when a parameter selects a tenant, make it impossible to call the function without it.

The last hole is machine tokens. When a token is requested for a person, that person gives consent. So any client, even one that belongs to nobody, may ask a person for a token to a declared resource. A client credentials token is different: the client gets it for itself, and nobody is asked. Until we noticed, the following worked. Any confidential client (a client with a secret of its own) of any tenant, or a client that had registered itself, could ask a victim tenant’s token endpoint for a token. And it got one, with the victim’s resource as the audience and the victim’s issuer. Now a machine token for a declared resource goes only to a client of the project that declared that resource. The specification says nothing about this. It would not: the question only exists when several tenants share one server.

Every agent may be granted every scope you list, so keep the list short

One small thing surprised me. Suppose the challenge in your 401 response carries no scope. Then the specification tells the client to request every scope in your scopes_supported, and says this is intended: a general-purpose client cannot choose sensibly among names it does not understand. So a long list in your metadata is not a menu of options. It is what every agent that ever connects will be granted.

So put the scope your tools actually need into the challenge. Keep scopes_supported to the minimum needed for basic use. When a privileged tool is first called, ask for more with a 403 response and the insufficient_scope error. The client is required to treat the scope in the challenge as authoritative. It also has to keep track, by itself, of the scopes it collects as it steps up, that is, asks for more rights one step at a time. That is why the authorization server does not have to remember any of it.

Our own administrative MCP endpoint lists openid and nothing else. Its authority comes from the groups the administrator belongs to, not from scopes. And a scope the endpoint never checks is not one to publish.

What the specification leaves to you: token format and the confused deputy

The first question is the token format, and the specification is careful not to decide it. An MCP server must reject tokens not intended for it “or otherwise verify that they are the intended recipient”. That wording allows two kinds of token. A self-contained token, like the example above, carries its data inside it and is checked against the published keys. An opaque token is a random string with no content. It is checked by introspection, which means asking the authorization server whether the token is still valid. We default to self-contained tokens that live fifteen minutes. The reason: introspection requires your MCP server to hold credentials of its own, and the ten-minute setup guide has no room for that step. The trade-off is that a self-contained token stays valid until it expires. If a revoked token has to stop working within seconds rather than minutes, declare the resource as opaque, give your MCP server a client of its own, and pay for an introspection request on every call. Neither answer is wrong. What is wrong is not knowing which one you chose.

The second question is the confused deputy problem: a middleman trusted with someone else’s authority gets turned against its owner. Solving it is not the authorization server’s job. Take an MCP server that proxies a third-party API. It uses one fixed client id with that API. It registers its own MCP clients dynamically. And it forwards authorization codes without showing a consent screen of its own. That is exactly the setup the security guidance walks through, attack by attack. The defence has two parts. First, the proxy asks for consent per client itself, before authorization at the third-party API starts. Second, it checks the audience, so that a token the proxy receives is never simply passed on. Every part of the specification I have described helps with this. None of it does the work for you.

What I do not know yet is how the trust policy for metadata documents will settle in practice, that is, which client URLs an authorization server should accept. The specification allows three approaches: accept any HTTPS client_id, allow only listed domains, or apply reputation checks. Each one opens a different way to phish users, because the name on the consent screen is whatever the document says. Open by default is what makes the ecosystem work. Allowlists are what an enterprise will want. I suspect the answer is both, chosen per tenant. That is one more question the specification will keep leaving to whoever runs the server.

If you want to see the whole thing end to end, Protect your MCP server with OAuth takes about ten minutes. It walks through the declaration, the two things your server publishes (its metadata and the 401 challenge), the token check, and both outcomes: a refusal and a success. Two settings decide how an agent host may identify itself: clientIdMetadataDocument.enabled for metadata documents and registration.enabled for dynamic registration. The endpoint reference lists what each discovery URL returns.