Authio.AspNetCore 0.6.0

dotnet add package Authio.AspNetCore --version 0.6.0
                    
NuGet\Install-Package Authio.AspNetCore -Version 0.6.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Authio.AspNetCore" Version="0.6.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Authio.AspNetCore" Version="0.6.0" />
                    
Directory.Packages.props
<PackageReference Include="Authio.AspNetCore" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Authio.AspNetCore --version 0.6.0
                    
#r "nuget: Authio.AspNetCore, 0.6.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Authio.AspNetCore@0.6.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Authio.AspNetCore&version=0.6.0
                    
Install as a Cake Addin
#tool nuget:?package=Authio.AspNetCore&version=0.6.0
                    
Install as a Cake Tool

Authio.AspNetCore

Protect an ASP.NET Core API (.NET 8 and .NET 10) with Authio:

  • Access-token validation with the standard JWT bearer handler, configured from the realm's discovery document: issuer, explicit audiences, asymmetric algorithms only, small clock skew, Authio's claim names kept as they are.
  • Introspection, optional: confirm on every request (cached per jti) that the token was not revoked and the session did not end, or validate opaque / HMAC / encrypted tokens purely by introspection.
  • Authio roles: the *_resource_access claims are flattened into role and permission claims, and the full model is one call away.
  • Authorization: scope, role, permission (Authio's exact rule), organization, step-up (acr / amr) and client requirements, as policy-builder helpers, minimal API helpers and controller attributes, with RFC 6750 / RFC 9470 WWW-Authenticate errors and optional problem details.
  • A typed caller: User.GetAuthioUser() (subject, client, session, scopes, roles, permissions, organization, account, acr/amr, delegation chain, profile).
  • Webhooks: a signed-delivery receiver with secret rotation, timestamp tolerance, de-duplication and typed handlers.
  • Downstream calls: forward the caller's token, or exchange it on behalf of the user (token exchange), cached.

It builds on Authio.Client; machine-to-machine calls use its AddAuthioClientCredentials.

dotnet add package Authio.AspNetCore

Quick start

{
  "Authio": {
    "Authority": "https://auth.example.com",
    "Realm": "acme",
    "Audiences": [ "https://orders.example.com" ]
  }
}
using Authio.AspNetCore.Defaults;
using Authio.AspNetCore.Extensions;

var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddAuthentication(AuthioJwtBearerDefaults.AuthenticationScheme)
    .AddAuthioJwtBearer(builder.Configuration.GetSection("Authio"));
builder.Services.AddAuthioAuthorization();

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

app.MapGet("/orders", (HttpContext context) => context.GetAuthioUser())
    .RequireAuthioScope("orders:read");

app.Run();

Token validation

AddAuthioJwtBearer registers the scheme AuthioBearer (AuthioJwtBearerDefaults.AuthenticationScheme, or any name with the (scheme, configuration, configure) overload). It reads the realm from AuthioOptions (Authority + Realm, or Issuer) and its own settings from AuthioJwtBearerOptions; the configuration overload binds both from the same section. With the Action<AuthioJwtBearerOptions> overload, configure AuthioOptions through services.AddAuthioClient(...).

What the handler checks:

Check Value
Metadata {issuer}/.well-known/openid-configuration through the Authio named HttpClient; keys from its jwks_uri
Issuer exactly {Authority}/realms/{Realm} (a different issuer in the discovery document is not trusted)
Audience any of Audiences (required)
Algorithms RS256/384/512, PS256/384/512, ES256/384/512 (ValidAlgorithms to narrow)
Lifetime exp/nbf required, ClockSkew default 30 s
Token type the typ claim must be Bearer: ID and refresh tokens are refused (RejectNonAccessTokens)
Claims MapInboundClaims = false: sub, scope, azp… keep their names
Name preferred_username, or sub when the token has none (client-credentials tokens)
Roles role claims flattened from *_resource_access (MapAuthorizationClaims) so IsInRole works

Several realms

Each scheme validates against its own realm. Its AuthioOptions are named after the scheme: they start as a copy of the shared ones (AddAuthioClient, or the default scheme's section), then take the scheme's own section, then services.Configure<AuthioOptions>(scheme, ...) registered after AddAuthioJwtBearer. A scheme that names no realm validates against the client's.

builder.Services
    .AddAuthentication(AuthioJwtBearerDefaults.AuthenticationScheme)
    .AddAuthioJwtBearer(builder.Configuration.GetSection("Authio"))
    .AddAuthioJwtBearer("Partners", builder.Configuration.GetSection("AuthioPartners"), null);

app.MapGet("/catalog", List).RequireAuthorization(policy => policy
    .AddAuthenticationSchemes(AuthioJwtBearerDefaults.AuthenticationScheme, "Partners")
    .RequireAuthenticatedUser());

Only the default scheme binds its section into the shared AuthioOptions. The Authio client — and with it introspection and on-behalf-of — stays on that one realm, so Introspection other than None on a scheme of another realm stops the application at startup.

Audiences

Authio sets aud to the RFC 8707 resource identifier when the client asked for a registered resource server (resource=https://orders.example.com on the authorize or token request), and otherwise to the audience configured on the client that requested it, its own client id unless changed: that token is addressed to the client, not to an API. So register your API as a resource server in Authio and accept its identifier:

"Audiences": [ "https://orders.example.com" ]

The application that calls the API has to ask Authio for a token for that resource: resource=https://orders.example.com on its authorize or token request, which with Authio.AspNetCore.Bff is Authio:Bff:Resource for the session's token, or Resource on a remote API or typed client (exchanged for it). A token without it is addressed to the client that requested it, and the API refuses it with 401:

WWW-Authenticate: Bearer error="invalid_token", error_description="The token is not addressed to this API: its audience is not 'https://orders.example.com'. Request the token for that resource (RFC 8707)."

The token's own audience is named too when the validation reports it, as IntrospectionOnly does.

Options

Key (Authio:) Default Meaning
Audiences — (required) Accepted audiences
Introspection None None, ActiveCheck or IntrospectionOnly
IntrospectionCacheDuration 00:00:30 How long an active answer is reused (never past exp); 0 disables
ClockSkew 00:00:30 Lifetime tolerance
ValidAlgorithms asymmetric list Accepted JWS algorithms
RequireHttpsMetadata Authio:RequireHttps HTTPS for discovery and keys
MapAuthorizationClaims true Add role / permission claims
RejectNonAccessTokens true Refuse tokens whose typ is not Bearer
SaveToken true Keep the token in the authentication properties

Anything else of JwtBearerOptions can be changed with services.Configure<JwtBearerOptions>(scheme, ...); an OnTokenValidated you set there runs after the Authio step.

Introspection

A JWT stays valid until it expires even after a logout, a revocation, a suspension or a withdrawn delegation. Authio's back-channel logout tells the client a person signed in to, not the APIs that accept its tokens, so two modes ask Authio's introspection endpoint instead. Both need services.AddAuthioClient(...) with a confidential client allowed to introspect the API's tokens (in Authio, the client the resource server names as its introspection client): Authio:ClientId and Authio:ClientSecret, or, with Authio:ClientAuthentication = PrivateKeyJwt, its private key (Authio:ClientPrivateKeyPath or Authio:ClientPrivateKey, plus Authio:ClientKeyId) so no secret is sent; see Authio.Client. On-behalf-of and the client-credentials handler authenticate the same way.

  • ActiveCheck: after local validation, the token must be active. Active answers are cached per jti for IntrospectionCacheDuration, which bounds how long a revoked token is still accepted. If Authio cannot be reached the request is refused (fail closed).
  • IntrospectionOnly: for clients that issue HMAC-signed or encrypted (JWE) tokens, which are not in the JWKS. The token is not read locally; the answer must be active, from the configured issuer, for an accepted audience and not expired. The principal is built from the answer (sub, azp from client_id, scope, preferred_username from username, aud, jti, exp…) plus the claims Authio adds to an active answer: the *_resource_access role maps, acr, amr, org_id/org_alias, act, sid and typ. Every requirement works as it does with local validation, and token_type is checked like typ, so refresh and ID tokens are refused. Needs an Authio server that writes those claims (October 2026 onwards); an older one answers without them, and role, permission, sign-in and organization requirements then refuse.
"Authio": {
  "Authority": "https://auth.example.com",
  "Realm": "acme",
  "ClientId": "orders-api",
  "ClientSecret": "<secret>",
  "Audiences": [ "https://orders.example.com" ],
  "Introspection": "ActiveCheck"
}
builder.Services.AddAuthioClient(builder.Configuration.GetSection("Authio"));
builder.Services
    .AddAuthentication(AuthioJwtBearerDefaults.AuthenticationScheme)
    .AddAuthioJwtBearer(builder.Configuration.GetSection("Authio"));
Who can introspect a token

Authio answers an introspection request according to who asks, as RFC 7662 lets an authorization server do (§2.2: a protected resource that "is not allowed to introspect this particular token" gets active: false; §4: the server authorizes which protected resources may introspect):

The token was requested… Its aud Introspected by the API's client (the resource server's introspection client) Introspected by the client that requested it
with resource=https://orders.example.com the resource active: true active: false, by design
without a resource the requesting client active: false active: true
  • The API is the token's audience, so the API is the one that introspects it: that is what ActiveCheck and IntrospectionOnly do, with the client the resource server names in Authio.
  • To the client that requested it, an access token for a resource is opaque (RFC 6749 §1.4): the client sends it and does not read it. Its own introspection answers active: false, which says nothing about whether the API accepts the token. A worker that wants to see its token introspected asks for one without a resource.
  • An active: false answer is refused with 401 and error_description="Authio reports the token as not active to this API's client. …": it was revoked, it expired, its session ended, or the API's Authio:ClientId is not the introspection client of the token's resource server.

The caller

app.MapGet("/me", (HttpContext context) =>
{
    var user = context.GetAuthioUser();          // or User.GetAuthioUser() in a controller
    var token = context.GetAuthioAccessToken();  // the raw bearer token
    var access = context.GetAuthioResourceAccess(); // roles model, parsed once per request
    return user;
}).RequireAuthorization();

AuthioUser holds Subject, ClientId (azp), SessionId (sid), TokenId (jti), Audiences, Scopes, Roles, Permissions (resource → methods), ResourceAccess (with the source of each role), OrganizationId / OrganizationAlias, AccountId, Acr / AuthenticationLevel, AuthenticationMethods (amr), IdentityProvider (idp, the external provider of a federated sign-in), AuthenticatedAt, Actor (the act chain of a delegated token, nested actors included), DelegationId, IsDelegated, Email, EmailVerified, Name and PreferredUsername.

Roles and permissions are only in tokens issued with the roles scope. Other principal helpers: GetAuthioScopes(), HasAuthioScope(scope), HasAuthioRole(role), HasAuthioPermission(resource, method), GetAuthioAuthenticationLevel(), GetAuthioAuthenticationMethods(), GetAuthioActor().

For Authio principals that come from another scheme (for example a cookie session signed in with OpenID Connect), services.AddAuthioClaimsTransformation() adds the same role / permission claims; it is idempotent.

Authorization

Every requirement is available three ways.

// Policies
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("orders.write", policy => policy
        .RequireAuthioScope("orders:write")
        .RequireAuthioPermission("orders", "write"));
});

// Minimal APIs (endpoints and route groups)
app.MapGet("/orders", List).RequireAuthioScope("orders:read", "orders:admin");
app.MapGroup("/admin").RequireAuthioRole("admin").RequireAuthioAuthenticationMethod("mfa");

// Controllers (needs services.AddAuthioAuthorization())
[AuthioScope("orders:read")]
[AuthioPermission("orders", "write")]
public IActionResult Create() => ...;
Requirement Helper / attribute Passes when
Scope RequireAuthioScope([match,] scopes) / [AuthioScope(..., RequireAll = true)] any (default) or all scopes are in scope, compared exactly
Role RequireAuthioRole([match,] [sources,] roles) / [AuthioRole(..., RequireAll = true, Sources = ...)] any or all roles are keys of a *_resource_access claim (or role claims), case-insensitive; with sources, only roles from those claims count (Client for client_resource_access, Realm, User, Plan, Consumer, Secret, System)
Permission RequireAuthioPermission(resource, method[, sources]) / [AuthioPermission(..., Sources = ...)] Authio's ResourceAccessRules.Grants: any role lists the method under the resource, case-insensitive; with sources, only roles from those claims count (e.g. System to reserve an action for platform roles)
Organization RequireAuthioOrganization(organization?) / [AuthioOrganization] org_id is present; with an argument, org_alias or org_id equals it (case-insensitive)
Step-up level RequireAuthioAuthenticationLevel(min) / [AuthioAuthenticationLevel] numeric acr ≥ min
Step-up method RequireAuthioAuthenticationMethod([match,] methods) / [AuthioAuthenticationMethod(..., RequireAll = true)] any or all methods are in amr (pwd, otp, swk, mca, fed, mfa…); fed is a sign-in through an external identity provider, counted as one factor, and comes with an idp claim holding the provider's alias (google, github)
Client RequireAuthioClient(clientIds) / [AuthioClient] azp is one of the clients

Several attributes on one action all apply. The attributes generate policy names (Authio:scope.any:...) that the AuthioAuthorizationPolicyProvider registered by AddAuthioAuthorization() turns into policies; every other policy name falls back to the policies you registered.

Refusals

With AddAuthioAuthorization():

  • a missing scope answers 403 with WWW-Authenticate: Bearer error="insufficient_scope", error_description="...", scope="orders:read orders:write" (RFC 6750 §3.1);
  • a failed step-up (acr / amr) answers 403 with WWW-Authenticate: Bearer error="insufficient_user_authentication", error_description="..." (RFC 9470), so the client can send the user through a stronger login (Authio honours acr_values on a best-effort basis: the client starts a new login with acr_values=2, or 3 for a passkey, and still checks the acr of the new token, because Authio issues the level the sign-in actually reached);
  • other refusals are a plain 403; an invalid or missing token is the bearer handler's 401 with error="invalid_token" and an error_description that says why: the bearer handler's own (expired, signature, issuer) or Authio's, for a token addressed to another audience, one that is not an access token, or one introspection reports as not active or cannot confirm. A refusal your own OnTokenValidated makes keeps its message out of the header; JwtBearerOptions.IncludeErrorDetails = false leaves every description out.
builder.Services.AddAuthioAuthorization(options =>
{
    options.WriteBearerErrors = true;     // default
    options.WriteProblemDetails = true;   // application/problem+json body on 401/403 (default false)
});

Webhooks

"Authio": {
  "Webhooks": {
    "Secret": "whsec_...",
    "Secrets": [ "whsec_previous..." ],
    "Tolerance": "00:05:00",
    "Deduplication": "Memory",
    "DeduplicationWindow": "1.00:00:00",
    "MaxBodySize": 1048576
  }
}
builder.Services
    .AddAuthioWebhooks(builder.Configuration.GetSection("Authio:Webhooks"))
    .AddHandler<AuditTrailHandler>()                              // IAuthioWebhookHandler: every event
    .AddHandler<AuthioWebhookUserData, UserSyncHandler>()         // IAuthioWebhookHandler<AuthioWebhookUserData>
    .On(AuthioWebhookEventTypes.KeysRotated, (evt, services, ct) => ...)
    .On<AuthioWebhookTokenRevokedData>(AuthioWebhookEventTypes.TokenRevoked, (evt, data, services, ct) => ...)
    .OnAny((evt, services, ct) => ...);

app.MapAuthioWebhooks();                 // POST /webhooks/authio, anonymous (the signature authenticates)

The receiver reads the raw body, verifies X-Authio-Signature (HMAC-SHA256 over {t}.{body} with the full whsec_ secret, constant-time) against every configured secret, so a rotation can list the old and new secrets, checks the timestamp against Tolerance, parses the event and skips an X-Authio-Event-Id it has already handled.

Answer When
200 handled, a duplicate, or ping
400 signed, but not an Authio event
401 missing, wrong or stale signature
413 body over MaxBodySize
500 a handler threw; the event id is forgotten so Authio's retry is handled again

Typed handlers receive the data shape of their events: AuthioWebhookUserData (user_created, user_updated, user_deleted), AuthioWebhookLoginData (login_succeeded, login_failed), AuthioWebhookUserLockedOutData, AuthioWebhookTokenRevokedData, AuthioWebhookKeysRotatedData, AuthioWebhookTokenReuseDetectedData, AuthioWebhookAdminResourceData (admin_resource_*), AuthioWebhookUserSuspensionData (user_suspended, user_suspension_ended) and AuthioWebhookPingData. Handlers run before Authio gets its answer: keep them short and enqueue long work. De-duplication is Memory (one instance), Distributed (register an IDistributedCache; the check and the write are two calls) or None; replace IAuthioWebhookDeduplicationStore for anything else.

Downstream calls

// Same audience downstream: forward the caller's token as is.
builder.Services.AddHttpClient<ProfileApi>(c => c.BaseAddress = new("https://profile.example.com"))
    .AddAuthioTokenForwarding();

// Another API, as the user: token exchange (RFC 8693). The delegated token keeps the user as sub,
// names this API in act, and has the downstream resource as aud.
builder.Services.AddAuthioClient(builder.Configuration.GetSection("Authio"));
builder.Services.AddHttpClient<BillingApi>(c => c.BaseAddress = new("https://billing.example.com"))
    .AddAuthioOnBehalfOf(options =>
    {
        options.Resource = "https://billing.example.com";
        options.Scopes = ["invoices:read"];
    });

// Another API, as this API itself (no user): client credentials, from Authio.Client.
builder.Services.AddHttpClient<ReportsApi>(c => c.BaseAddress = new("https://reports.example.com"))
    .AddAuthioClientCredentials(options => options.Resource = "https://reports.example.com");

On-behalf-of needs AddAuthioClient with this API's confidential client, allowed to exchange tokens in Authio. The delegated token is cached per incoming token (jti), resource and scopes until Authio:TokenRefreshSkew before it expires (concurrent requests share one exchange); on a downstream 401 it is dropped and the call retried once. IAuthioOnBehalfOfTokenCache can also be used directly.

Testing

tests/Authio.Testing in the repository is an in-memory Authio (discovery, JWKS, token, introspection…) used by this package's tests; route the Authio named client to it with services.AddFakeAuthioBackchannel(fake) and mint tokens with fake.Tokens.CreateAccessToken(...).WithAudience(...).

License

MIT.

Product 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.6.0 47 10/9/2026
0.5.1 57 10/8/2026
0.5.0 50 10/8/2026
0.4.0 82 10/6/2026
0.3.0 102 10/5/2026
0.2.0 91 10/4/2026
0.1.0 93 10/4/2026