Authio.Mcp.AspNetCore
0.2.0
dotnet add package Authio.Mcp.AspNetCore --version 0.2.0
NuGet\Install-Package Authio.Mcp.AspNetCore -Version 0.2.0
<PackageReference Include="Authio.Mcp.AspNetCore" Version="0.2.0" />
<PackageVersion Include="Authio.Mcp.AspNetCore" Version="0.2.0" />
<PackageReference Include="Authio.Mcp.AspNetCore" />
paket add Authio.Mcp.AspNetCore --version 0.2.0
#r "nuget: Authio.Mcp.AspNetCore, 0.2.0"
#:package Authio.Mcp.AspNetCore@0.2.0
#addin nuget:?package=Authio.Mcp.AspNetCore&version=0.2.0
#tool nuget:?package=Authio.Mcp.AspNetCore&version=0.2.0
Authio.Mcp.AspNetCore
Protect an ASP.NET Core MCP server with Authio.
The MCP server is an OAuth 2.1 resource server; Authio is its authorization server. This package wires the server's half of that contract:
- Token validation — JWT bearer against the realm's published keys: signature (asymmetric algorithms only: RS/PS/ES 256/384/512), issuer, audience (your resource identifier) and expiry.
- Introspection (RFC 7662) — every token that passes local validation is checked with Authio, because only
Authio knows whether it was revoked or its session ended. An active answer is cached per
jtifor 30 seconds. - Protected resource metadata (RFC 9728) — the document an MCP client reads to discover Authio and the scopes to request.
- MCP-ready challenges —
401withWWW-Authenticate: Bearer … resource_metadata="…"and403witherror="insufficient_scope", scope="…". - Connected accounts — trade the caller's Authio token for the provider token of an account they connected (GitHub, Google…) through Authio's token exchange (RFC 8693).
- Client authentication — introspection and the exchange authenticate as the server's own client with
client_secret_basic,client_secret_postorprivate_key_jwt(RFC 7523). - Start-up checks — the realm's metadata is read when the application starts; a wrong base URL, realm or issuer stops it with a sentence instead of every token being refused later.
Targets .NET 8 and .NET 10. It does not depend on the MCP SDK; it works with any endpoint you protect.
Install
dotnet add package Authio.Mcp.AspNetCore
dotnet add package ModelContextProtocol.AspNetCore
Register the server in Authio
In the Authio console, in your realm:
- Create a resource server whose identifier is the public URL of your MCP endpoint, e.g.
https://mcp.example.com/mcp, with a scope such asmcp:tools. - Create a confidential client for the MCP server itself and set it as the resource server's
introspection client. Its id goes in
ClientId. It can authenticate with any of the three methods Authio accepts —client_secret_basic(the default),client_secret_postorprivate_key_jwt; see Client authentication:- with a secret, the secret goes in
ClientSecret; - with
private_key_jwt, register the client's public key in the console under the client's Security step, as a JWK Set or a JWKS URL, and give this server the private key.
- with a secret, the secret goes in
- For connected accounts, add a connection provider (e.g. alias
github) and allow the MCP clients that call you to use it.
Configure
appsettings.json (keep the secret in user-secrets or an environment variable: Authio__Mcp__ClientSecret):
{
"Authio": {
"Mcp": {
"BaseUrl": "https://auth.example.com",
"Realm": "demo",
"ResourceUrl": "https://mcp.example.com/mcp",
"ClientId": "mcp-server",
"ClientSecret": "<secret>",
"RequiredScopes": [ "mcp:tools" ],
"ResourceName": "My MCP server"
}
}
}
| Setting | Meaning |
|---|---|
BaseUrl |
Where Authio answers, without the realm. Must match the base URL Authio publishes (Auth:PublicBaseUrl): the issuer {BaseUrl}/realms/{Realm} is built from it. |
Realm |
The realm the resource server and its clients live in. |
ResourceUrl |
The public URL of the MCP endpoint. It is the resource identifier and the token's aud (scheme and host lower-cased, default port dropped, trailing slash trimmed — as Authio stores it). |
ClientId |
The resource server's introspection client. Also used for the token exchange. |
ClientAuthentication |
How that client authenticates: Basic (client_secret_basic, default), Post (client_secret_post) or PrivateKeyJwt (private_key_jwt). |
ClientSecret |
The client's secret. Required with Basic and Post; never sent with PrivateKeyJwt. |
PrivateKey |
PrivateKeyJwt only: the client's unencrypted private key in PEM — PKCS#8 (BEGIN PRIVATE KEY), PKCS#1 (BEGIN RSA PRIVATE KEY) or SEC1 (BEGIN EC PRIVATE KEY). |
PrivateKeyId |
PrivateKeyJwt only, optional: the kid the assertion names, matching the key in the client's JWK Set. |
SigningAlgorithm |
PrivateKeyJwt only, optional: RS256/RS384/RS512/PS256/PS384/PS512 for RSA, ES256/ES384/ES512 for EC on the matching curve. Default: RS256 for RSA, the curve's algorithm for EC (ES256 for P-256). |
RequiredScopes |
Scopes every token must carry. Default: mcp:tools. |
ResourceName |
Optional resource_name in the protected resource metadata. |
IntrospectionCacheDuration |
How long an active introspection answer is trusted. Default: 00:00:30; 00:00:00 disables the cache. |
Every setting can also be set in code: AddAuthioMcp(options => { … }) runs after the configuration binding.
Client authentication
Introspection and the connected-accounts exchange are calls this server makes as its own OAuth client. Both
authenticate the same way, with the method set in ClientAuthentication; it must be one the client is allowed to use
in Authio, or Authio answers invalid_client.
client_secret_basic (default) — the id and secret in an HTTP Basic header, each percent-encoded first:
{ "Authio": { "Mcp": { "ClientId": "mcp-server", "ClientSecret": "<secret>" } } }
Authio__Mcp__ClientId=mcp-server
Authio__Mcp__ClientSecret=<secret>
client_secret_post — the id and secret as client_id / client_secret in the form body:
{ "Authio": { "Mcp": { "ClientId": "mcp-server", "ClientAuthentication": "Post", "ClientSecret": "<secret>" } } }
Authio__Mcp__ClientAuthentication=Post
Authio__Mcp__ClientSecret=<secret>
private_key_jwt (RFC 7523) — no secret travels. For every request the server signs a fresh assertion with the
client's private key: iss and sub = ClientId, aud = the realm's issuer ({BaseUrl}/realms/{Realm}), a unique
jti, valid for 2 minutes. It is sent as client_assertion with
client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer and client_id.
Create a key pair, keep the private key with the server, and register the public key on the client in the Authio console (Security step, as a JWK Set or a JWKS URL):
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out mcp-server.key # RSA → RS256
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out mcp-server.key # or EC P-256 → ES256
openssl pkey -in mcp-server.key -pubout -out mcp-server.pub # the public half, to turn into a JWK
{
"Authio": {
"Mcp": {
"ClientId": "mcp-server",
"ClientAuthentication": "PrivateKeyJwt",
"PrivateKey": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBg…\n-----END PRIVATE KEY-----",
"PrivateKeyId": "mcp-server-2026",
"SigningAlgorithm": "PS256"
}
}
}
Authio__Mcp__ClientAuthentication=PrivateKeyJwt
Authio__Mcp__PrivateKey="$(cat mcp-server.key)"
Authio__Mcp__PrivateKeyId=mcp-server-2026
PrivateKeyId and SigningAlgorithm are optional. When the key lives elsewhere — a vault, a certificate — hand the
signing credentials over in code instead; they take precedence over PrivateKey, and the key's KeyId becomes the
kid:
builder.Services.AddAuthentication().AddAuthioMcp(options =>
{
options.ClientAuthentication = AuthioMcpClientAuthentication.PrivateKeyJwt;
options.SigningCredentials = new SigningCredentials(
new RsaSecurityKey(rsaFromVault) { KeyId = "mcp-server-2026" },
SecurityAlgorithms.RsaSha256);
});
A missing key, a PEM that is not an unencrypted RSA or EC private key, or an algorithm the key cannot sign with stops the application at start-up, naming the setting.
Use with the official C# MCP SDK
using Authio.Mcp.AspNetCore.Defaults;
using Authio.Mcp.AspNetCore.Extensions;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication().AddAuthioMcp();
builder.Services.AddCors(cors => cors.AddAuthioMcpPolicy());
builder.Services.AddHttpContextAccessor();
builder.Services
.AddMcpServer()
.WithHttpTransport(transport => transport.Stateless = true)
.WithToolsFromAssembly();
var app = builder.Build();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.MapAuthioProtectedResourceMetadata();
app.MapMcp("/mcp")
.RequireAuthorization(AuthioMcpDefaults.Policy)
.RequireCors(AuthioMcpDefaults.CorsPolicy);
app.Run();
MapAuthioProtectedResourceMetadata() serves the document at /.well-known/oauth-protected-resource{resource path}
— for https://mcp.example.com/mcp that is /.well-known/oauth-protected-resource/mcp:
{
"resource": "https://mcp.example.com/mcp",
"authorization_servers": [ "https://auth.example.com/realms/demo" ],
"scopes_supported": [ "mcp:tools" ],
"bearer_methods_supported": [ "header" ],
"resource_name": "My MCP server"
}
A tool that reads the caller and uses a connected account
using System.ComponentModel;
using Authio.Mcp.AspNetCore.Exchanges;
using Authio.Mcp.AspNetCore.Extensions;
using Authio.Mcp.AspNetCore.Models;
using ModelContextProtocol.Server;
[McpServerToolType]
public sealed class GitHubTools
{
[McpServerTool(Name = "github_account", ReadOnly = true)]
[Description("Shows which GitHub account the caller connected through Authio.")]
public static async Task<string> GitHubAccount(
IHttpContextAccessor accessor,
IAuthioConnectionExchange exchange,
[Description("Which connected account to use, when there are several.")] string? connectionId = null,
CancellationToken cancellationToken = default)
{
var context = accessor.HttpContext!;
var caller = context.GetAuthioCaller(); // sub, azp, scopes, org_id, org_alias, jti, exp
var subjectToken = context.GetAuthioAccessToken()!; // the verified Authio access token
var result = await exchange.ExchangeAsync(subjectToken, "github", connectionId, cancellationToken);
return result switch
{
AuthioConnectionGranted granted =>
$"{caller!.Subject} is {granted.ExternalAccount?.Login} on GitHub.", // use granted.AccessToken
AuthioConnectionRequired required =>
$"Connect your GitHub account first: {required.ConnectUri}",
AuthioConnectionSelectionRequired selection =>
"Pick one: " + string.Join(", ", selection.Connections.Select(c => $"{c.Id} ({c.ExternalLogin})")),
AuthioConnectionRefused refused =>
$"Authio refused ({refused.Error}, {refused.Code}): {refused.Description}",
_ => "Unexpected result."
};
}
}
Return each refusal to the model as text the person can act on: the address where they connect the account, the accounts to choose from, or the reason Authio gave.
A runnable version lives in samples/Authio.Mcp.Sample.
Challenges
| Situation | Status | WWW-Authenticate |
|---|---|---|
| No token | 401 | Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp" |
| Invalid, expired, foreign-audience or revoked token | 401 | Bearer error="invalid_token", error_description="…", resource_metadata="…" |
| Token without the required scopes | 403 | Bearer error="insufficient_scope", scope="mcp:tools", resource_metadata="…" |
If Authio refuses the introspection request itself (wrong client credentials), the request fails with a 500 and the
error is logged naming the settings to check: Authio:Mcp:ClientId, Authio:Mcp:ClientAuthentication and
Authio:Mcp:ClientSecret — or Authio:Mcp:PrivateKey with private_key_jwt.
Browser-based MCP clients (MCP Inspector)
A browser client reads the protected resource metadata cross-origin and must read WWW-Authenticate from the 401
to find it. The metadata endpoint already answers any origin. For the MCP endpoint, register the CORS policy and
add it to the endpoint, with app.UseCors() before app.UseAuthentication():
builder.Services.AddCors(cors => cors.AddAuthioMcpPolicy());
// …
app.UseCors();
app.MapMcp("/mcp").RequireAuthorization(AuthioMcpDefaults.Policy).RequireCors(AuthioMcpDefaults.CorsPolicy);
The policy allows any origin, the MCP headers (Authorization, Content-Type, Mcp-Session-Id,
Mcp-Protocol-Version, Last-Event-ID) and exposes WWW-Authenticate and Mcp-Session-Id. If you use your own
policy, expose WWW-Authenticate in it.
API
| Member | Purpose |
|---|---|
AuthenticationBuilder.AddAuthioMcp(Action<AuthioMcpOptions>?) |
Registers the scheme, the policy, introspection, the exchange and the start-up check. |
IEndpointRouteBuilder.MapAuthioProtectedResourceMetadata() |
Serves the RFC 9728 document. |
CorsOptions.AddAuthioMcpPolicy(string name = AuthioMcpDefaults.CorsPolicy) |
CORS policy for browser MCP clients. |
AuthioMcpDefaults.Policy / .AuthenticationScheme / .CorsPolicy / .HttpClientName |
Names to refer to. |
AuthioMcpClientAuthentication (Basic, Post, PrivateKeyJwt) |
How the server authenticates to introspection and the exchange. |
IAuthioConnectionExchange.ExchangeAsync(subjectToken, provider, connectionId?, ct) |
Connected-accounts token exchange. |
HttpContext.GetAuthioAccessToken() |
The verified raw token (subject token for the exchange). |
HttpContext.GetAuthioCaller() / ClaimsPrincipal.GetAuthioCaller() |
AuthioCaller (subject, client, scopes, organization, token id, expiry). |
ClaimsPrincipal.GetAuthioScopes() / HasAuthioScopes(scopes) |
Scope checks, e.g. per tool. |
IAuthioTokenIntrospector |
Replaceable introspection service. |
All calls to Authio (metadata, keys, introspection, exchange) go through the named HttpClient
AuthioMcpDefaults.HttpClientName, so you can add a proxy, a handler or resilience with
services.AddHttpClient(AuthioMcpDefaults.HttpClientName)….
License
MIT
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.AspNetCore.Authentication.JwtBearer (>= 10.0.10)
-
net8.0
- Microsoft.AspNetCore.Authentication.JwtBearer (>= 8.0.23)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.