Skip to content
Guides/Detail

Your MCP server is a resource server, not an authorisation server

19/08/2026 (edited)
Tech
1617 Words
8Min read

An MCP server that speaks OAuth is a resource server. It verifies tokens somebody else issued. It holds no client secret, hosts no consent screen, and mints nothing.

That single sentence decides most of the design. What it does not tell you is that turning it on takes three independent settings on your identity provider, and each one fails in a way that looks like a different bug. Two of them let sign-in succeed first, which is what makes them expensive.

This is the wire protocol, the three settings, and one curl to diagnose each failure.

Your server holds no secret

The split is clean. The authorisation server owns the user, hosts the consent screen, and signs access tokens. Your MCP server checks signatures.

Checking a signature needs the provider’s public JWKS and nothing else. No client secret belongs in your worker, your container, or your environment file.

If you are reaching for a client secret to add auth to an MCP server, you have started building an authorisation server by accident. Stop and use one that exists.

The practical version, from the server behind this piece:

export type OAuthConfig = { issuer: string; jwksUri: string; audience: string };

/** All-or-nothing, deliberately. A half-configured box stays token-only rather
 *  than half-opening the OAuth path. */
export function oauthConfig(env: Env): OAuthConfig | null {
  const issuer = env.WORKOS_ISSUER?.trim();
  const jwksUri = env.WORKOS_JWKS_URI?.trim();
  const audience = env.WORKOS_AUDIENCE?.trim();
  if (!issuer || !jwksUri || !audience) return null;
  return { issuer, jwksUri, audience };
}

Three public values. Make it all-or-nothing so a half-configured deployment stays closed instead of half-open.

Two requests start the whole dance

A client that has never seen your server does this:

  1. It calls your MCP endpoint with no token.
  2. You answer 401 with a www-authenticate header naming where your metadata lives.
  3. It fetches that metadata and learns which authorisation server to talk to.
  4. It registers itself, sends the user through consent, and comes back with a token.

Step 2 is the entire trigger. A bare 401 with no header ends the conversation, because the client has no way to discover anything.

export function wwwAuthenticate(cfg: OAuthConfig): string {
  return (
    `Bearer error="invalid_token", ` +
    `error_description="Authentication required", ` +
    `resource_metadata="${metadataUrl(cfg)}"`
  );
}

Here is that challenge on the wire, from a live deployment:

$ curl -si -X POST https://mcp.example.com/mcp -d '{}' | grep -i 'HTTP\|www-authenticate'
HTTP/2 401
www-authenticate: Bearer error="invalid_token", error_description="Authentication
required", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp"

If your unauthenticated call returns 401 with no www-authenticate, or returns 404, or returns 200 with an error body, nothing downstream can work. Test this first, before you touch any provider settings.

The metadata URL is not where you think

RFC 9728 section 3.1 inserts the well-known segment before the resource path. It does not append it.

For a server at https://mcp.example.com/mcp, the document is at https://mcp.example.com/.well-known/oauth-protected-resource/mcp.

Not https://mcp.example.com/.well-known/oauth-protected-resource, which is the one everybody serves first because it is the shape every other well-known document uses.

/** https://host/mcp -> https://host/.well-known/oauth-protected-resource/mcp */
export function metadataUrl(cfg: OAuthConfig): string {
  const u = new URL(cfg.audience);
  return `${u.origin}/.well-known/oauth-protected-resource${u.pathname}`;
}

Serve both paths. The insertion rule is the one clients follow, the bare path costs you three lines, and the failure when you get it wrong is silent. The document itself is small:

{
  "resource": "https://mcp.example.com/mcp",
  "authorization_servers": ["https://your-tenant.example-idp.com"],
  "scopes_supported": [],
  "bearer_methods_supported": ["header"]
}

resource is the full URL of your MCP endpoint, and it is also the aud your tokens must carry. One value doing two jobs, which is what keeps a staging token from opening production.

Three settings, three different broken

This is the part no documentation assembles for you. On the provider side, three things must be true. They are independent. None implies another, and each was found by it failing.

Setting What it gives you What missing it looks like
OAuth resource registered The token aud and the metadata resource Tokens rejected, or issued for the wrong audience
Dynamic Client Registration enabled Clients can register themselves at connect time Fails the instant you click connect
JWT template with the claims you need email and friends inside the access token Sign-in succeeds, then the first tool call fails

Registering the resource is the step everyone finds, because it is the one the MCP documentation talks about. It is also the one that gets you the least. On its own it leaves an authorisation server that no MCP client can register against, and that presents as a broken connector rather than as a missing setting.

The third row is the expensive one. Read it again.

Why does the connector fail the moment I click connect?

The client cannot get a client id.

An MCP client arrives with no pre-registered client id, because there is no onboarding step where a developer pastes one in. It has to obtain one at connect time. If the authorisation server offers it no way to do that, the flow dies at the first redirect, and what the user sees is a connector that does not work.

There are two mechanisms, and you need to check for both, because a server that supports either one is fine:

$ curl -s https://your-tenant.example-idp.com/.well-known/oauth-authorization-server \
    | grep -oE '"(registration_endpoint|client_id_metadata_document_supported)":[^,}]*'
"client_id_metadata_document_supported":true
"registration_endpoint":"https://your-tenant.example-idp.com/oauth2/register"

If neither appears, no MCP client can register against this provider without a human pasting in credentials, and no amount of correct code on your side changes it.

Which mechanism to prefer has changed, and recently. Client ID Metadata Documents are now the recommended path: the client uses an HTTPS URL that hosts its own metadata as its client_id, so nothing has to be registered at all. The current MCP authorisation draft marks Dynamic Client Registration deprecated, kept for backwards compatibility with servers that do not support metadata documents, and puts the client priority order at pre-registration, then metadata documents, then DCR, then prompting the user.

So do not diagnose this on registration_endpoint alone, which is what an older guide (including an earlier version of this one) will tell you. An authorisation server can support metadata documents, have no registration endpoint at all, and work perfectly.

On WorkOS AuthKit specifically, both are booleans on updateAuthkitSettings, isAuthkitClientIdMetadataDocumentEnabled and isAuthkitDynamicClientRegistrationEnabled, and neither is part of registering the OAuth resource. Passing only those two is non-destructive to the rest of the settings. Enabling both is the pragmatic choice today: metadata documents for clients that support them, DCR for the ones that have not caught up.

Why does sign-in succeed and the first tool call fail?

Because the access token verified perfectly and carried no email.

Identity providers do not put user profile claims into an access token by default. An ID token is not an access token, and the claims you saw during login are not the claims your server receives afterwards.

So consent completes, the connector reports success, and then every call that has to work out who this is fails against a token that is cryptographically valid.

Say so in the logs, or you will debug it twice:

const email = claims.email?.trim().toLowerCase();
if (!email) {
  // A verified token for an unknown person, carrying no email. Almost always
  // one cause: the environment has no JWT template, so `email` is never minted
  // into the access token.
  console.warn(
    `oauth: verified sub ${claims.sub} has no email claim - the environment is ` +
      `probably missing its JWT template; cannot provision`,
  );
  return null;
}

Two things about the fix. Add a JWT template that mints the claims you need. Then remove the connector and add it again, because retrying reuses the token you already have, and that token still has no email in it. Retrying looks like the fix did not work.

Pin the algorithm or the rest is theatre

One more line carries more weight than everything above it.

Verify with the algorithm you chose, never the one named in the token header. A caller who picks the algorithm has two bypasses available:

  • alg: none, which is a token with no signature at all.
  • HS256, which uses the JWKS public key as an HMAC secret. That key is public by definition, so anyone can mint a valid token.

Pin to RS256, reject everything else before reading a single claim, and write a test for each bypass. Both are two-line tests. Both stay green forever and neither is ever wasted.

What the specification does not tell you

The MCP specification describes the wire protocol accurately. Everything above about metadata, challenges and path insertion is in there or in the RFC it points at.

What is not in there is the shape of the failures. Nothing tells you that a correctly registered OAuth resource, on its own, produces a connector that looks broken. Nothing tells you that a missing claim template turns a successful login into a failure one request later. Those are provider-side facts that show up only in a production rollout, and the gap between “the protocol is documented” and “I can make this work” is exactly that list.

Three settings. Three symptoms. One curl each.

Back to guides
End of Post