MCP servers
Protect an MCP server so MCP clients such as Claude Code find your realm, sign the person in and call your tools with a token issued for your server alone.
In the Model Context Protocol authorization model, your MCP server is an OAuth resource server and Authio is its authorization server. The server never signs anyone in: an MCP client — Claude Code, an IDE, the MCP Inspector — discovers Authio from the server, signs the person in through the hosted login and calls your tools with an access token whose audience is your server.
Two packages implement the server’s half: authio-mcp 0.1.0 for Node.js, on top of the official MCP SDK, and Authio.Mcp.AspNetCore 0.1.0 for ASP.NET Core. Both verify tokens, publish the metadata MCP clients look for and answer with the challenges that start the OAuth flow.
How an MCP client finds Authio
- The client calls the server without a token. The server answers
401with aWWW-Authenticate: Bearerheader whoseresource_metadatapoints at its protected resource metadata. - The client reads that document (RFC 9728), served under
/.well-known/oauth-protected-resourcefollowed by the endpoint’s path. It names the realm as the authorization server. - It reads the realm’s authorization server metadata (RFC 8414) at the path-inserted address MCP clients try first,
https://authio.api.hyperio.tech/.well-known/oauth-authorization-server/realms/acme; the realm’s OpenID Connect discovery address answers too. - It identifies itself with a client ID you registered for it, or with the URL of its own Client ID Metadata Document if the realm accepts those. Authio does not offer dynamic client registration.
- It runs the authorization code flow with PKCE and
resource=https://mcp.example.com/mcp(RFC 8707). The access token carries that identifier asaud, so it is useless on any other server. - Your server checks the signature against the realm’s keys, the issuer, the audience, the expiry and the required scopes, then asks Authio’s introspection endpoint whether the token is still active — at most once every 30 seconds per token.
{
"resource": "https://mcp.example.com/mcp",
"authorization_servers": ["https://authio.api.hyperio.tech/realms/acme"],
"scopes_supported": ["mcp:tools"],
"bearer_methods_supported": ["header"],
"resource_name": "Acme tools"
}Set up the realm
Register the server as a resource server
In Resource servers, set the Identifier to the public URL of the MCP endpoint, such as
https://mcp.example.com/mcp, and add a scope such asmcp:tools. The identifier becomes every token’saudand cannot change later; it is stored with the scheme and host lower-cased and without a default port or trailing slash.Create the server’s own client
In Clients, create a confidential client for the server (
client_secret_basic) and choose it as the resource server’s Introspection client. Its ID and secret go in the server’s configuration. Give it the token exchange grant if tools will use connected accounts.Let MCP clients in
Either register a client for each MCP client you use and add it under Allowed clients, or turn on Accept CIMD clients so MCP clients that publish a metadata document can ask for tokens. Both are described below.
Pre-registered clients
- A public client with the
authorization_codegrant, PKCE required andS256allowed. - The redirect URI of each MCP client: Claude Code uses
http://localhost:<callback-port>/callback, the MCP Inspectorhttp://localhost:6274/oauth/callback. Turn on Loopback on any port for native clients that pick their port. - Require OpenID Connect off, because MCP clients usually ask only for your scope — or have them ask for
openidtoo. - The client listed under Allowed clients on the resource server; otherwise the authorize request fails with
invalid_target.
Then give Claude Code the client ID and the callback port you registered:
claude mcp add --transport http --client-id acme-claude-code --callback-port 8765 acme-tools https://mcp.example.com/mcpClient ID Metadata Documents
A Client ID Metadata Document (CIMD) lets an MCP client identify itself with an https URL that serves a JSON description of it — its name and redirect URIs — instead of a client ID you created. The URL is the identity: a document that names a different client_id is refused.
- Turn it on per realm: in the realm’s MCP clients (CIMD) settings, switch on Accept clients by metadata document (CIMD). It is off by default.
- Optionally limit Allowed hosts. Empty accepts any public
httpshost;*.example.comcovers the subdomains ofexample.combut notexample.comitself. - Turn on Accept CIMD clients on each resource server those clients may reach.
- The client is registered on first use, is always public, always shows the consent screen and stays in sync with its document. Turning the setting off, or removing its host, shuts it out on the next request.
- The realm’s discovery document advertises
client_id_metadata_document_supported: truewhile the setting is on.
Node.js: authio-mcp
authio-mcp needs Node.js 20.9 or later and is ESM only. express is needed only for the authio-mcp/express helpers.
npm install authio-mcp @modelcontextprotocol/sdk expresscreateAuthio reads the realm’s metadata at start-up, so a wrong base URL, realm or issuer stops the server with a sentence instead of every token being refused later. authioMetadataRouter publishes the protected resource metadata and answers CORS for browser clients such as the MCP Inspector; mount it at the root. callerOf(extra.authInfo) gives each tool the subject, clientId, scopes, organizationId, organizationAlias, expiresAt and the raw token.
Not on Express? authio.verifier is an OAuthTokenVerifier for the MCP SDK’s bearer middleware, and authio.metadata is the realm’s authorization server metadata.
ASP.NET Core: Authio.Mcp.AspNetCore
The package targets .NET 8 and .NET 10 and does not depend on the MCP SDK, so it protects any endpoint; here it sits in front of the official C# SDK.
dotnet add package Authio.Mcp.AspNetCore
dotnet add package ModelContextProtocol.AspNetCoreKeep the secret in user-secrets or the Authio__Mcp__ClientSecret environment variable. AddAuthioMcp(options => …) sets anything in code after the configuration is bound, and ClaimsPrincipal.HasAuthioScopes(scopes) checks a scope per tool.
| Node option | .NET setting | Meaning |
|---|---|---|
baseUrl | BaseUrl | Where Authio answers, without the realm. Must be the base Authio publishes in its issuer. |
realm | Realm | The realm the resource server lives in. |
resourceUrl | ResourceUrl | The public URL of the MCP endpoint: the resource identifier and every token’s aud. |
clientId, clientSecret | ClientId, ClientSecret | The resource server’s introspection client; also used for connected-account exchanges. |
requiredScopes | RequiredScopes | Scopes every token must carry. Default mcp:tools. |
resourceName | ResourceName | The name the metadata gives the server. |
introspectionTtlMs | IntrospectionCacheDuration | How long an active answer is trusted. Default 30 seconds. |
What the server answers
| Situation | Status | WWW-Authenticate carries |
|---|---|---|
| No token | 401 | resource_metadata |
| Invalid, expired, revoked, or issued for another resource | 401 | error="invalid_token", a description and resource_metadata |
| Token without the required scopes | 403 | error="insufficient_scope" and the scopes needed |
Browser-based clients such as the MCP Inspector must read that header across origins. Both packages expose WWW-Authenticate through CORS — the Express router by default, ASP.NET Core with AddAuthioMcpPolicy and RequireCors.
Use the person’s connected accounts
A tool can trade the caller’s Authio token for the token of an account they connected in Authio — GitHub, Google Drive and others — without storing any third-party credential. Call authio.exchangeConnection({ subjectToken, provider }) in Node or IAuthioConnectionExchange.ExchangeAsync(subjectToken, provider, connectionId) in .NET, where provider is the connection provider’s alias.
| Result | What to tell the person |
|---|---|
| Granted | Nothing: use the provider’s access token. Authio never hands out the provider’s refresh token. |
| Connection required | The address where they connect the account (connectUri). |
| Selection required | The accounts to choose from; call again with one connectionId. |
| Refused | The reason, such as access_denied when the plan does not include connected accounts. |
In Node, describeConnectionRefusal(result, 'GitHub') words any result other than granted for the person.
Try it end to end
curl -i -X POST https://mcp.example.com/mcp
curl https://mcp.example.com/.well-known/oauth-protected-resource/mcp
curl https://authio.api.hyperio.tech/.well-known/oauth-authorization-server/realms/acmeThe first call should answer 401 with resource_metadata, the second name your realm, the third describe it. Then connect a real client: add the server to Claude Code as shown above, or open the MCP Inspector with npx @modelcontextprotocol/inspector, point it at the endpoint and start the OAuth flow from its connection settings.
Troubleshooting
| What you see | Why | What to do |
|---|---|---|
invalid_target at the authorize request | The MCP client may not ask for this resource. | List its client under Allowed clients, or turn on Accept CIMD clients for a CIMD client. |
invalid_scope — scope must include openid | The client requires OpenID Connect and the MCP client asked only for your scope. | Turn Require OpenID Connect off on that client. |
| The hosted login refuses the redirect URI | The MCP client’s callback is not registered exactly. | Register it with its port, or turn on Loopback on any port. |
| The server does not start: Authio names a different issuer | baseUrl is not the base Authio publishes in its issuer. | Use the issuer without /realms/acme. |
| Every token refused: not issued for this resource | resourceUrl differs from the resource server’s identifier. | Make them identical, scheme, host and path included. |
Every request fails with 500 | Authio refused the introspection request. | Check the client ID and secret of the introspection client. |