Skip to content

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

  1. The client calls the server without a token. The server answers 401 with a WWW-Authenticate: Bearer header whose resource_metadata points at its protected resource metadata.
  2. The client reads that document (RFC 9728), served under /.well-known/oauth-protected-resource followed by the endpoint’s path. It names the realm as the authorization server.
  3. 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.
  4. 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.
  5. It runs the authorization code flow with PKCE and resource=https://mcp.example.com/mcp (RFC 8707). The access token carries that identifier as aud, so it is useless on any other server.
  6. 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.
GET /.well-known/oauth-protected-resource/mcpJSON
{
  "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

Resource servers and tokens issued for them need the MCP integration in your account’s plan, and connected accounts need their own feature. See Plans and billing.
  1. 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 as mcp:tools. The identifier becomes every token’s aud and cannot change later; it is stored with the scheme and host lower-cased and without a default port or trailing slash.

  2. 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.

  3. 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_code grant, PKCE required and S256 allowed.
  • The redirect URI of each MCP client: Claude Code uses http://localhost:<callback-port>/callback, the MCP Inspector http://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 openid too.
  • 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:

TerminalBash
claude mcp add --transport http --client-id acme-claude-code --callback-port 8765 acme-tools https://mcp.example.com/mcp

Client 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 https host; *.example.com covers the subdomains of example.com but not example.com itself.
  • 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: true while 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.

TerminalBash
npm install authio-mcp @modelcontextprotocol/sdk express

createAuthio 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.

TerminalBash
dotnet add package Authio.Mcp.AspNetCore
dotnet add package ModelContextProtocol.AspNetCore

Keep 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 settingMeaning
baseUrlBaseUrlWhere Authio answers, without the realm. Must be the base Authio publishes in its issuer.
realmRealmThe realm the resource server lives in.
resourceUrlResourceUrlThe public URL of the MCP endpoint: the resource identifier and every token’s aud.
clientId, clientSecretClientId, ClientSecretThe resource server’s introspection client; also used for connected-account exchanges.
requiredScopesRequiredScopesScopes every token must carry. Default mcp:tools.
resourceNameResourceNameThe name the metadata gives the server.
introspectionTtlMsIntrospectionCacheDurationHow long an active answer is trusted. Default 30 seconds.

What the server answers

SituationStatusWWW-Authenticate carries
No token401resource_metadata
Invalid, expired, revoked, or issued for another resource401error="invalid_token", a description and resource_metadata
Token without the required scopes403error="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.

ResultWhat to tell the person
GrantedNothing: use the provider’s access token. Authio never hands out the provider’s refresh token.
Connection requiredThe address where they connect the account (connectUri).
Selection requiredThe accounts to choose from; call again with one connectionId.
RefusedThe 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

TerminalBash
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/acme

The 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 seeWhyWhat to do
invalid_target at the authorize requestThe 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 openidThe 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 URIThe 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 issuerbaseUrl is not the base Authio publishes in its issuer.Use the issuer without /realms/acme.
Every token refused: not issued for this resourceresourceUrl differs from the resource server’s identifier.Make them identical, scheme, host and path included.
Every request fails with 500Authio refused the introspection request.Check the client ID and secret of the introspection client.

Next steps