Authorizing an MCP server
A realm can act as the authorization server for a Model Context Protocol server, so clients like Claude Code and Claude Desktop sign your users in and receive a token scoped to your MCP server — without you registering each client by hand. This page walks the whole setup and the mistakes that are easy to make.
How it fits together
Three parties, and it helps to keep them straight:
| Party | Who that is |
|---|---|
| Authorization server | Your SemAuth realm. Signs users in and issues the token. |
| Resource server | Your MCP server. Validates the token and serves the tools. |
| Client | Claude Code, Claude Desktop, or another MCP client. |
The client identifies itself with a Client ID Metadata Document (CIMD): its
client_id is an HTTPS URL, and the realm fetches that URL to learn the client's redirect URIs.
You never create the client — you decide which origins may register, and the realm does the rest.
Before you start
- A realm that is not your organization's admin realm. Self-registration is refused there, because that realm gates your console.
- At least one sign-in method enabled on the realm (magic link, passkey, or a federated provider).
- Your MCP server's canonical URI — the exact value it publishes as
resourcein its protected-resource metadata.
Step 1 — register your MCP server as an API
Do this first. A client that registers before the API exists is created without any audience it may request, and connecting will fail until you fix that client by hand.
Go to APIs → Register a resource server:
| Field | Value |
|---|---|
| Identifier | A short name, e.g. acme-mcp. Cannot be changed later. |
| Audience | Your MCP server's canonical URI, e.g. https://mcp.acme.com/mcp. |
| Scopes | At least one, e.g. mcp:use. See the note on scopes below. |
The audience must match your metadata's resource byte for byte. It is
compared verbatim and stamped into the token's aud, so
https://mcp.acme.com/mcp and https://mcp.acme.com/mcp/ are different servers as far
as the realm is concerned. Getting this wrong does not fail at setup — it fails later, when your MCP server
rejects a token that looks otherwise valid.
Step 2 — allow the client's origin to register
On the realm's settings page, under Client self-registration, add the hosts whose metadata documents you will accept, then turn the feature on:
claude.ai
chatgpt.com
Entries are an exact host or a subdomain wildcard (*.claude.ai). A wildcard does not match its
own apex — list both if you want both. Wildcards over a top-level domain (*.com) or a shared
hosting domain (*.example-host.app) are refused, because they would admit every tenant of that
provider.
The allowlist is the control, not the switch. The document is fetched over HTTPS from the origin named in
the client_id, so that origin is vouching for its own redirect URIs. An empty list accepts
nothing, and the feature cannot be enabled without at least one host.
How the client authenticates
You do not configure this — the client's metadata document declares it, and the realm accepts two methods:
| Method | What it means |
|---|---|
none | A public client. PKCE proves the code was not intercepted. This is what Claude Code uses. |
private_key_jwt | The client signs a short-lived assertion with a private key
and publishes the public half at jwks_uri, which the realm fetches to verify it. Stronger,
and what ChatGPT's connector declares. |
The client_secret_* methods are refused, and not as a local restriction: a shared secret
cannot be established with a client that registered itself by publishing a file, so the CIMD specification
forbids them in a metadata document.
A client's jwks_uri may live on a different domain from its metadata document — that is
ordinary, and the realm does not require it to appear in your allowlist. It must be HTTPS and must resolve to
a public address.
Step 3 — point your MCP server at the realm
Your MCP server publishes protected-resource metadata (RFC 9728). Two fields matter:
{
"resource": "https://mcp.acme.com/mcp",
"authorization_servers": ["https://{issuer_id}.semauth.app"],
"scopes_supported": ["mcp:use"]
}
resource— must equal the audience you registered in step 1.authorization_servers— must be the realm's issuer exactly. If the realm has a custom domain, that is the issuer; the canonical{issuer_id}.semauth.apphost still answers, but it reports the custom domain as itsissuer, and clients compare that string exactly. Read/.well-known/oauth-authorization-serverand copy theissuervalue you find there.scopes_supported— see below.
Return a 401 with a WWW-Authenticate header pointing at that document when a
request arrives without a token. That is how a client discovers where to send the user.
About scopes
If your metadata declares no scopes_supported and your 401 names no
scope, the client has nothing to ask for. The realm then issues a token whose scope
claim is empty. That is harmless if your server does not check scopes — but if it does, declare the scope in
your metadata and register the same one on the API, or every request will be refused for a permission the
client was never told to request.
What your users see
- The client opens the realm's sign-in page.
- The user signs in with whatever the realm allows.
- A consent screen names the application and the host the token will be sent to. For a desktop client that host is on the user's own machine, and the screen says so — the realm cannot verify which local program is listening.
- The client receives the token and calls your MCP server.
Consent is asked once per user per client, and can be withdrawn from the user's account page. Withdrawing it stops future authorizations; it does not revoke tokens already issued.
What your MCP server receives
An RS256 JWT, verifiable against the realm's /jwks.json:
| Claim | Value |
|---|---|
iss | The realm's issuer URL. |
aud | The audience you registered — check this. |
sub | The user's stable identifier within the realm. |
scope | The granted scopes, intersected with what the API declares. |
exp / iat / jti | One-hour lifetime. |
Validate that aud names your server and reject anything else. A token minted for another
resource must not be accepted, even from the same realm.
Troubleshooting
| What you see | What it means |
|---|---|
audience not in client's allowed_audiences |
The client registered before you created the API, so it holds no audience. Register the API, then have the client re-register — or fix the existing client's audiences. |
audience is not a known visible API |
The resource does not match any API registered by this organization. Compare it
character by character, including the trailing slash. |
client_id host … is not allowed by this realm |
The client's metadata URL is on a host you have not allowlisted. |
client id metadata documents are not enabled for this realm |
Self-registration is off for this realm. |
this realm does not accept self-registered clients |
The realm is an admin realm. Use an ordinary realm. |
Invalid redirect_uri |
The callback is not among those the metadata document declares. |
token_endpoint_auth_method … is not supported |
The document asks for a method the realm does not accept — in practice one of the
client_secret_* family. Only none and private_key_jwt are
available to a self-registered client. |
declares private_key_jwt but no jwks_uri |
The document names the method without saying where its public keys are, so no assertion could be verified. |
| The token is opaque, not a JWT | No resource reached the realm. Confirm the client sends it and that your metadata
names the right authorization server. |