Skip to content
Chapter 4 of 8 · MCP authBuilding an MCP resource server

16 min read

Building an MCP resource server

On this page

A remote MCP server is a resource server in OAuth terms: it holds no passwords, runs no consent screen and issues no tokens, and its entire share of the protocol is publishing what it is, challenging callers who arrive without a credential, and refusing tokens that were not minted for it. Everything it needs to decide comes from a token somebody else signed and a document it serves itself.

Both halves are small, and both have details that break clients rather than the server. A metadata document at the wrong path, a challenge without its resource_metadata parameter, a validation option deleted during an incident, or an issuer chosen from the token being validated — each produces a server that works for the callers it was tested with and fails, sometimes silently and sometimes catastrophically, for the ones it was not.

Two endpoints you serve before anyone is authenticated

Here is what the omission looks like from the far side, in a trace attached to a bug report:

http
HTTP/1.1 401 Unauthorized
Content-Type: application/json

{"jsonrpc":"2.0","id":null,"error":{"code":-32001,"message":"Unauthorized"}}

There is no WWW-Authenticate header. A client arriving with nothing but a URL has no next move from that response, and reporting it as "does not support OAuth" is a fair description of the observable behaviour. The 401 is not an error message for a human; it is the first document in a protocol.

The metadata lives at a well-known path derived from the resource identifier. For a server whose canonical identifier is https://mcp.northwind-analytics.com/mcp, the path component is inserted after the well-known segment, giving /.well-known/oauth-protected-resource/mcp. For a server that lives at the origin root, the path is /.well-known/oauth-protected-resource. Serving only the second when your resource has a path means a client that follows RFC 9728 correctly gets a 404, so serve both if you are unsure which one clients will try — they return the same document, and the cost is one route.

ts
import { Hono } from "hono";

const RESOURCE = "https://mcp.northwind-analytics.com/mcp";
const ISSUER = "https://auth.northwind-analytics.com";

const app = new Hono();

const metadata = {
  resource: RESOURCE,
  authorization_servers: [ISSUER],
  scopes_supported: ["reports:read", "reports:write", "accounts:read"],
  bearer_methods_supported: ["header"],
  resource_documentation: "https://docs.northwind-analytics.com/mcp",
};

for (const path of [
  "/.well-known/oauth-protected-resource",
  "/.well-known/oauth-protected-resource/mcp",
]) {
  app.get(path, (c) => {
    c.header("Cache-Control", "public, max-age=3600");
    c.header("Access-Control-Allow-Origin", "*");
    return c.json(metadata);
  });
}

The Access-Control-Allow-Origin header on that route, and the matching CORS configuration on the MCP endpoint itself — CORS being the browser rule that a page may only read a response from another origin when that origin's server says so in its headers — decide whether browser-based clients can talk to you at all. A browser client cannot read a response header the server has not explicitly exposed, which means the WWW-Authenticate on your 401 is invisible to it unless you send Access-Control-Expose-Headers: WWW-Authenticate. Leaving that out produces exactly the symptom above, for browser clients only, on a server that looks correct from curl. It is worth testing from a browser context once rather than reasoning about it.

The chain producing that bug report is worth following, because every link in it is a correct behaviour. The browser makes the cross-origin request and receives your 401 with its header intact; nothing is stripped in transit. The browser then decides which of those headers the page's JavaScript may see, and the default list is short and does not include WWW-Authenticate. So the client code reads a 401 with no challenge attached, which is exactly the shape a server that has never heard of RFC 9728 produces. It concludes the server does not support OAuth, and it is not wrong about what it observed. The same server is therefore correct for curl, which has no such rule and prints every header it receives, and broken for a browser — and the difference is a header you did not send rather than anything about the response you did.

The challenge itself is a header on every unauthenticated or badly authenticated response:

ts
function challenge(error?: string, description?: string, scope?: string) {
  const parts = [`Bearer resource_metadata="${RESOURCE_METADATA_URL}"`];
  if (error) parts.push(`error="${error}"`);
  if (description) parts.push(`error_description="${description}"`);
  if (scope) parts.push(`scope="${scope}"`);
  return parts.join(", ");
}

The resource_metadata parameter is present on every challenge, including the ones that carry an error, because a client whose token has gone bad may need to restart discovery and should not have to have kept the URL from months ago. The error and error_description parameters come from RFC 6750 and are what turn a failure into a diagnosis; the scope parameter appears only on the insufficient-scope case, where it names what the client should ask for next.

The order of the checks, and what each one costs

Validation is a fixed sequence, and it runs to completion before any tool code is reached. Doing it in a different order is not wrong so much as wasteful — verifying a signature on a token that is three days expired spends a millisecond to learn something a date comparison would have told you — but doing it partially is the failure that matters.

There is a stricter reason for the order than cost, and it is what makes the sequence fixed rather than merely tidy. Every check after the first reads a claim out of the token — iss, then aud, then exp — and a claim is only a statement by the issuer once the signature verifies. Before that point the payload is a string the caller supplied, so comparing it against anything reports what the caller wrote rather than what the issuer said. Putting the audience check first therefore does more than waste a comparison: it performs the comparison against untrusted data, and then, if it passes, performs the signature check that would have made the comparison meaningful. The answer comes out right in every case, which is precisely why the mistake survives review — it becomes wrong only when somebody later deletes the signature check for a reason that looked local.

CheckMechanismFailure responseTypical cost
Token present and well-formedParse Authorization: Bearer <jwt>401, no error parameterunder 0.05 ms
SignatureJWKS from the issuer's jwks_uri, keys cached and refreshed on unknown kid401 invalid_token0.1–0.3 ms warm, 40–120 ms on a cold JWKS fetch
issExact string match against the configured issuer401 invalid_tokennegligible
audExact string match against this server's canonical resource identifier401 invalid_tokennegligible
exp / nbfCompared to server clock with at most 60 seconds of skew allowance401 invalid_tokennegligible
Scope for the requested toolGranted scopes ⊇ scopes the tool declares403 insufficient_scope, with scope= in the challengenegligible
Revocation (opaque tokens only)Introspection call to the authorization server401 invalid_token5–20 ms in-datacentre

Everything above the last row is arithmetic on a string you already hold, which is why JWT validation is usually quoted at well under a millisecond and why "we cache validated tokens for the session to save time" is optimising something that costs nothing. The expensive part is the JWKS fetch — the JSON Web Key Set is the document of public signing keys the issuer publishes at its jwks_uri, and the kid in a token's header names which of those keys signed it — and it is amortised across every request the key signs; a sane client library fetches on startup, caches for an hour, and re-fetches once when it sees a kid it does not recognise, which is how key rotation is supposed to feel from the outside.

Adding that column up puts the point more sharply than the sentence does. Parsing costs under a twentieth of a millisecond. A warm signature check costs between a tenth and three tenths. The issuer, audience and expiry comparisons and the scope check are string and integer operations over values already in memory, which is why the table records them as negligible rather than as small. The whole warm path therefore totals under half a millisecond, and the only two rows that can exceed it are the cold JWKS fetch, which happens once per signing key rather than once per request, and introspection, which applies only to opaque tokens. Caching validated tokens for the session saves that half-millisecond and pays for it by attaching authority to something which is not the token — a poor trade at any price, and here the price being saved is not measurable beside a single network round trip.

ts
import { createRemoteJWKSet, jwtVerify } from "jose";

const jwks = createRemoteJWKSet(new URL(`${ISSUER}/oauth2/v1/keys`));

export async function authenticate(authHeader: string | undefined) {
  const token = authHeader?.match(/^Bearer (.+)$/)?.[1];
  if (!token) throw new AuthError(401);

  const { payload } = await jwtVerify(token, jwks, {
    issuer: ISSUER,
    audience: RESOURCE,       // exact match, not a prefix, not a list membership
    clockTolerance: 60,       // seconds
  });

  return {
    subject: payload.sub as string,
    scopes: new Set(String(payload.scope ?? "").split(" ").filter(Boolean)),
  };
}

The audience: RESOURCE option is the whole of chapter 3, on what the aud claim binds, expressed as one line of configuration, and it is also the line most likely to be deleted during an incident. A common sequence: an integration fails, someone discovers the audience mismatch, the fastest way to unblock the customer is to drop that option, the customer is unblocked, and the line never comes back. What remains is a server that accepts any token the issuer signed for anything, which is the shape chapter 5, on forwarding a token upstream, is about.

Never let the token choose its own verifier

A multi-tenant MCP server that accepts tokens from several customer identity providers has one extra decision to make, and there is a natural-looking way to make it that removes all the security from everything above. The token carries an iss claim. That claim names an issuer, and issuer metadata names a jwks_uri, so it is a short step to fetching the keys from wherever the token says and verifying against those. A verifier built that way accepts every token in the world: an attacker mints their own JWT with any sub, any scope, and any aud they like, hosts a JWKS document containing their public key at a domain they own, sets iss to that domain, and your server dutifully fetches the attacker's key and confirms that the attacker's signature is valid. It is valid. It just is not anybody's authority.

The issuer has to be selected from something outside the token. In a single-tenant deployment that is a constant in your configuration. In a multi-tenant one it is resolved from the request's routing — a per-tenant subdomain, a path segment, a client certificate — and used to look up an allowlisted issuer and its cached key set, with the token's own iss claim then compared against that value as a check rather than consulted as an instruction. The same rule applies to aud: a server that serves several resource identifiers resolves the expected one from the route it is serving, not from the claim it is validating.

ts
const TENANTS: Record<string, { issuer: string; resource: string }> = {
  "acme.mcp.example.com": {
    issuer: "https://acme.okta.com/oauth2/aus1f",
    resource: "https://acme.mcp.example.com/mcp",
  },
  "globex.mcp.example.com": {
    issuer: "https://login.globex.internal",
    resource: "https://globex.mcp.example.com/mcp",
  },
};

const tenant = TENANTS[requestHost];        // from routing, never from the token
if (!tenant) throw new AuthError(401);

The general rule underneath that is worth stating on its own, because it appears in this topic in three disguises. A check is only a check when its expected value comes from somewhere the party being checked cannot reach. Here that means the issuer and the expected audience are taken from your configuration or your routing table, and the token's own iss and aud are compared against them rather than consulted for them — the claim is the thing under test, never the source of the answer. The same rule decides that a tool must not take the acting user's identity from an argument the caller supplied, which is the store key in chapter 6, on holding per-user grants at an upstream provider. And it is why chapter 3's audience check cannot be repaired by relaxing it: relaxing means allowing the token to nominate a wider set of acceptable values, which is the same move arriving by a politer route.

Choosing an identifier you will be stuck with

The canonical resource identifier is a string you publish, register as an audience with the authorization server, and then cannot change without invalidating every token in flight and every client's stored configuration. Pick it once, deliberately. The MCP endpoint's own URL is the right default because it is what clients already have and what discovery derives paths from, and it should be the exact form the server actually serves on, including the path. Resist the temptation to use something prettier — a bare domain, a urn: value, the name of the internal service — because a client following the spec sends the string from your metadata, and a string that does not resemble your endpoint makes every subsequent debugging conversation harder.

Multi-tenant deployments face one further choice: one resource identifier for the whole service, or one per tenant. A single shared identifier means a token minted for tenant A's users is, as far as the audience check is concerned, valid at tenant B's endpoint, and the only thing separating the tenants is whatever your tool code does with the sub claim. That is a single missing predicate away from a cross-tenant read. Per-tenant identifiers put the separation in the token itself, which costs one registration per tenant in the IdP and is worth it above a handful of customers.

"A single missing predicate away" is worth walking, because it is the step that makes a shared identifier a decision rather than a naming convention. With one resource identifier across the service, a token minted for a user of tenant A carries that identifier, and tenant B's endpoint validates against the same string, so the audience check passes at B. The signature passes too, if both tenants sit behind one issuer. Expiry passes. Scope passes, because the scope vocabulary is the service's rather than the tenant's. Every check in the table above has now returned true for a request that crossed a customer boundary, and the only thing standing between it and somebody else's records is whatever the tool's query does with sub. That is one where clause, written by whoever wrote that tool, unenforced by anything and unmentioned by the type system. Per-tenant identifiers move the separation into a check that runs before any tool code, which is the difference between a boundary held by the framework and a boundary held by a convention.

Multi-issuer deployments have a second reason, and it is the same fact that keys the store in chapter 6. Subject values are unique within an issuer and carry no promise beyond it, so two customer identity providers can both emit the same sub. If the audience does not separate the tenants, and the subject does not either, then a tool comparing subjects is comparing strings from two different namespaces as though they came from one — and it will find matches that are not the same person. The pair that identifies a user is the issuer and the subject together, never the subject alone, wherever that pair is used: as a database key, as a cache key, or as the predicate in a query.

What you record about all this matters for the same reason. Log the jti — the token's own unique identifier, which lets two log lines be tied to one credential without storing the credential — along with the sub, the client_id, the issuer, the requested tool and the decision; do not log the Authorization header, and do not log the raw token in a validation-failure path, which is where it most often ends up because whoever wrote the error handler wanted to see what was wrong with it. A token in an application log is a live credential sitting in a system with a much broader read population than the service it was issued for, and chapter 3's containment argument is the only reason that leak stays survivable.

Scope belongs to the tool, not to the server

Authentication is one decision for the whole request; authorization is one decision per tool. A server that validates the token at the transport layer and then dispatches to any tool the client names has answered "is this a real user" and never answered "may this user do this".

ts
const TOOL_SCOPES: Record<string, string[]> = {
  "reports.list": ["reports:read"],
  "reports.export": ["reports:read"],
  "reports.publish": ["reports:write"],
  "accounts.lookup": ["accounts:read"],
};

function authorizeTool(name: string, granted: Set<string>) {
  const required = TOOL_SCOPES[name] ?? [];
  const missing = required.filter((s) => !granted.has(s));
  if (missing.length) throw new AuthError(403, "insufficient_scope", missing);
}

Two things follow from declaring scopes per tool rather than per server. The scopes_supported array in your metadata becomes a real list a client can reason about, so a client that received a read-only grant can tell the user that reports.publish is unavailable instead of discovering it at call time. And the 403 you return names the missing scope, so a client that wants to fix it knows what to request — which is what makes the 401 and 403 distinction in chapter 8 actionable rather than cosmetic.

Where the check has to sit

Validation runs per request, on every request, in the middleware in front of the JSON-RPC dispatcher. Two placements that feel equivalent are not. Doing it inside each tool implementation means a tool added next quarter by someone who copied a different template is unauthenticated, and nothing will tell you. Doing it once per session and caching the result attaches authority to the transport session id, which chapter 1 already noted has none of its own; a token revoked at 14:02 keeps working until the session ends, and a long-lived streamable HTTP session can outlive the token by hours.

There is a check the framework will not do for you, which is that the subject in the token is entitled to the specific rows the tool touches. A token with reports:read says the user may read reports; it does not say which reports, and reports.export(report_id) with an id the user does not own will happily return someone else's data if the tool trusts its arguments. Scope is a coarse capability grant, and the per-record decision belongs in the query — a tenant predicate, a row-level security policy, an ownership check — exactly as it would in the REST API next door. The presence of an OAuth layer changes nothing about that obligation and, because it feels like security work has been done, makes it easier to forget.

That is the belief this chapter's own success makes most likely, which is why it belongs at the end rather than the beginning. Everything above is real security work, it is visible in the code, and it produces refusals a reviewer can watch happen — so a request that has passed all of it feels finished. What has actually been established is that a named user, authenticated by a trusted issuer, holds a grant broad enough to invoke this tool. Nothing has been established about the arguments. reports.export(report_id) asks the tool to fetch one specific record, and the token contains no statement about records, because a scope is a vocabulary of capabilities rather than a list of rows. The missing check is the one no framework can write for you, since only the query knows which rows belong to whom — and the OAuth layer makes it easier to forget precisely because it makes the request look inspected.

The server's obligations are therefore short enough to list and specific enough to test: publish the metadata at both derived paths with CORS headers a browser can read, challenge every unauthenticated request with a header naming where that metadata lives, validate signature, issuer, audience and expiry per request in middleware with the issuer and expected audience taken from configuration or routing rather than from the token, answer 403 naming the missing scope, and then make the per-record entitlement decision the token cannot make for you.

The remaining question is what your server does after the checks pass. It has a validated identity, a set of scopes, and a tool to run — and that tool frequently needs to call something else, an internal service or a third-party API, which has its own idea of what a valid credential looks like. The cheapest-looking way to make that call is to reuse the token already in your hand, and it is the mistake the next chapter takes apart.

MCP auth — read the current version at https://daviddominguez.dev/learn/en/mcp-auth/building-an-mcp-resource-server

Back to top