Protect your MCP server with OAuth
An MCP server that anyone can call is a set of tools with no front door. The MCP authorization specification adds that door by making the MCP server an OAuth 2.1 protected resource: an agent host that reaches it without a token is told where to get one, goes and gets it, and comes back with a token minted for that MCP server and nothing else.
You will declare your MCP server in FoxAuth, publish a metadata document and a 401 challenge from it,
check one token, and then watch your MCP server refuse an anonymous call and accept an authorized one.
This takes about ten minutes if you already have an instance running. If you do not, Run with Docker
Compose adds about two more.
1. Declare your MCP server as a resource
Section titled “1. Declare your MCP server as a resource”Declare it in a project that has an address of its own: a path like /acme, a subdomain like
acme.auth.example.com, or a hostname. Such a project is its own issuer. A resource identifier is unique only within its issuer,
so nothing about your MCP server is fetched or checked when you declare it. It can be on an internal
network, on localhost while you develop, or not deployed yet. Another tenant with a project of its own
may declare the same URL without affecting yours.
A project with no address of its own is served at the instance’s root issuer, which every such project shares. At the root issuer, only a super administrator can declare a resource, because the resource’s own metadata cannot say which of those projects it belongs to.
-
In the console, open your project and choose its APIs tab, then New resource.
-
Fill in three things.
Resource identifier: the canonical URI of your MCP server, exactly as its clients will name it, for example
https://mcp.example.com/mcp. No fragment, and no trailing slash: the specification asks implementations to prefer the slash-free form, and this server stores it that way.Name: what the end-user sees on the consent screen.
Scopes it recognises: the permissions your tools distinguish. Start with one, something like
mcp:tools-basic. -
Leave the token settings alone. The defaults are a self-contained token your MCP server verifies against this server’s published keys, with a fifteen-minute lifetime. The self-contained format is why section 3 below, Validate the token, needs no credentials of its own.
-
Choose Connect on the API’s row. Connect shows the values the next sections use, ready to copy: the issuer, the exact audience its tokens carry, the protected resource metadata document and the challenge line.
2. Publish what an MCP server has to publish
Section titled “2. Publish what an MCP server has to publish”Your MCP server serves both of these itself: a metadata document, and a 401 challenge that points to
it.
Protected resource metadata
Section titled “Protected resource metadata”A JSON document at /.well-known/oauth-protected-resource, saying which authorization server protects
you. The specification makes it a MUST for an MCP server, as it does the challenge below that points
to it and the audience check in section 3:
{ "resource": "https://mcp.example.com/mcp", "authorization_servers": ["https://auth.example.com/acme"], "scopes_supported": ["mcp:tools-basic"], "bearer_methods_supported": ["header"]}resource is the identifier you declared in section 1, character for character. authorization_servers
names the issuer of the project you declared the resource in: the project’s own issuer when it has an
address (https://auth.example.com/acme, https://acme.auth.example.com, or its hostname), and the
instance’s ISSUER otherwise. The
document may live at /.well-known/oauth-protected-resource followed by your resource’s path, as RFC
9728 forms it, at the root as above, or wherever the challenge below points.
The resource’s row in the console shows whether this document currently vouches for your declaration, and which of the three places it was found at. This check is a diagnostic only: it blocks nothing.
A challenge that says where to go
Section titled “A challenge that says where to go”When a request arrives with no token, or a token you reject, answer 401 with a WWW-Authenticate
header pointing at that document:
HTTP/1.1 401 UnauthorizedWWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource", scope="mcp:tools-basic"The scope is a courtesy the specification asks for and clients use: without it, a client goes back to
your metadata document and requests everything in scopes_supported.
3. Validate the token
Section titled “3. Validate the token”Your MCP server has to establish two things about every token: that it is genuine, and that it was issued for you. The second is not optional. Accepting a token minted for something else opens the confused-deputy hole: your MCP server would act on authority that was granted to another service. Audience binding exists to close that hole, and the specification forbids accepting such a token outright.
With the default token format there is nothing to ask this server at request time. Fetch the issuer’s
public keys once from the jwks_uri its metadata advertises, cache them, and make four checks. A
project with an address signs with keys of its own, so its key set is beneath its own issuer
(https://auth.example.com/acme/jwks in the example below) and holds no other tenant’s keys:
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', // The identifier you declared. This is the check that makes the token yours. audience: 'https://mcp.example.com/mcp' });
return { subject: payload.sub, scopes: (payload.scope ?? '').split(' ') };}jwtVerify checks the signature and exp for you; issuer and audience are the two you must state.
Any JWT library in any language does the same four checks. The shape above is the checklist; the
library is your choice.
4. Let an agent host identify itself
Section titled “4. Let an agent host identify itself”An agent host usually knows nothing but a URL, and has nowhere for you to type a client identifier. The
current specification’s answer is Client ID Metadata Documents: the client’s client_id is an
HTTPS URL naming a JSON document that describes it. This server fetches and validates that document
on demand, and creates no client record at all.
The project’s APIs tab says whether agent hosts can identify themselves this way. On your own
server, a super administrator turns this support on in Settings:
clientIdMetadataDocument.enabled.
The setting is in force as soon as you save it. Once it is on, discovery advertises
client_id_metadata_document_supported, which is what a client checks before trying.
5. Prove it refuses and accepts
Section titled “5. Prove it refuses and accepts”Make three calls. The refusals matter as much as the success, because without them there is no telling “protected” from “not reached yet”.
-
Call your MCP server with no token. You should get
401, and theWWW-Authenticateheader from section 2. If you get200, your validation is most likely not wired into the request path.Terminal window curl -i https://mcp.example.com/mcp -X POST \-H 'content-type: application/json' \-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' -
Get a token and call again. Point your agent host at your MCP server and let it complete the flow: it should read your metadata document, discover this server, sign your end-user in, and come back with a token. The same call now answers
200. -
Try the token somewhere it does not belong. Declare a second resource, get a token for it, and present that one to your MCP server. It must be refused, because the
audclaim will not match. The audience check stops one integration’s token working at another’s, and it is worth seeing it refuse a token once.
What you did not have to do
Section titled “What you did not have to do”No code in this server, and no restart for the resource itself: a declaration is data, read on every token request. No client secret for your MCP server, because it verifies tokens against published keys instead of asking this server. And no allowlist of agent hosts for an ordinary resource: which end-users a client can sign in follows from the resource it names, so the project that holds the resource owns that answer.
The endpoints this page touches are in the endpoints reference, and every setting named above is in the settings reference with its default and the argument for it.