--- name: aspnetcore-authentication description: ASP.NET Core authentication middleware configuration including OpenID Connect, JWT Bearer, cookie authentication, authentication schemes, challenge/forbid flows, and external identity provider integration. invocable: false --- # ASP.NET Core Authentication ## When to Use This Skill Use this skill when: - Configuring OIDC authentication in an ASP.NET Core web application - Setting up JWT Bearer authentication for an API - Managing authentication schemes (cookies, OIDC, JWT, external providers) - Implementing challenge, sign-in, sign-out, and forbid flows - Debugging authentication failures (401s, redirect loops, claim mapping issues) - Integrating with Duende IdentityServer as an OpenID Connect provider - Configuring token validation parameters ## Core Principles 1. **Authentication ≠ Authorization** — Authentication establishes *who* the user is. Authorization (see `aspnetcore-authorization`) determines *what* they can do. 2. **Scheme-Based Architecture** — ASP.NET Core authentication is built around named schemes. Each scheme has a handler that knows how to authenticate, challenge, and sign out. 3. **Cookies for Web Apps, JWT for APIs** — Web applications use cookie authentication (with OIDC for login). APIs use JWT Bearer or introspection. 4. **Never Roll Your Own** — Use the built-in OIDC and JWT Bearer handlers. They handle nonce validation, key rotation, token validation, and dozens of edge cases. 5. **Claim Type Mapping Matters** — The OIDC handler maps JWT claim types to .NET claim types by default. Disable this for predictable claim names. ## Related Skills - `aspnetcore-authorization` — Policy-based authorization after authentication - `identityserver-configuration` — Server-side client and resource configuration - `identityserver-sessions-providers` — Server-side sessions to reduce cookie size and maintain IdP-side data - `oauth-oidc-protocols` — Protocol fundamentals underlying these handlers - `token-management` — Automatic token refresh with Duende.AccessTokenManagement Docs: https://docs.duendesoftware.com/identityserver/apis/aspnetcore/jwt/ --- ## Pattern 1: OIDC Authentication for Web Applications The most common pattern — a server-rendered web app authenticating users via Duende IdentityServer: ```csharp var builder = WebApplication.CreateBuilder(args); builder.Services.AddAuthentication(options => { options.DefaultScheme = "Cookies"; options.DefaultChallengeScheme = "oidc"; }) .AddCookie("Cookies", options => { options.Cookie.Name = "myapp"; options.Cookie.SameSite = SameSiteMode.Lax; options.ExpireTimeSpan = TimeSpan.FromHours(8); options.SlidingExpiration = true; }) .AddOpenIdConnect("oidc", options => { options.Authority = "https://identity.example.com"; options.ClientId = "web.app"; options.ClientSecret = "secret"; options.ResponseType = "code"; // Authorization code flow // Map scopes to request options.Scope.Clear(); options.Scope.Add("openid"); options.Scope.Add("profile"); options.Scope.Add("email"); options.Scope.Add("api1"); options.Scope.Add("offline_access"); // For refresh tokens // Save tokens in the authentication cookie options.SaveTokens = true; // Disable Microsoft's JWT claim type mapping options.MapInboundClaims = false; // Where to get additional user claims options.GetClaimsFromUserInfoEndpoint = true; options.TokenValidationParameters = new TokenValidationParameters { NameClaimType = "name", RoleClaimType = "role" }; }); var app = builder.Build(); app.UseAuthentication(); app.UseAuthorization(); ``` ### Critical Settings Explained | Setting | Why | Default | |---------|-----|---------| | `MapInboundClaims = false` | Prevents renaming `sub` → `http://schemas.xmlsoap.org/.../nameidentifier` | `true` (maps) | | `SaveTokens = true` | Stores access/refresh tokens in the cookie for later API calls | `false` | | `GetClaimsFromUserInfoEndpoint = true` | Fetches full profile claims from userinfo | `false` | | `ResponseType = "code"` | Authorization code flow (PKCE is automatic in .NET 7+) | `"code"` (.NET 7+; was `"code id_token"` in earlier versions) | --- ## Pattern 2: JWT Bearer Authentication for APIs APIs validate access tokens issued by IdentityServer: ```csharp var builder = WebApplication.CreateBuilder(args); builder.Services.AddAuthentication("Bearer") .AddJwtBearer("Bearer", options => { options.Authority = "https://identity.example.com"; options.Audience = "catalog-api"; // Must match ApiResource name options.MapInboundClaims = false; // Must be included options.TokenValidationParameters = new TokenValidationParameters { ValidateAudience = true, ValidAudience = "catalog-api", NameClaimType = "name", RoleClaimType = "role" }; }); builder.Services.AddAuthorization(); var app = builder.Build(); app.UseAuthentication(); app.UseAuthorization(); // Protect endpoints app.MapGet("/products", () => Results.Ok()) .RequireAuthorization(); ``` ### Multiple Audiences When an API accepts tokens from multiple resources: ```csharp options.TokenValidationParameters = new TokenValidationParameters { ValidateAudience = true, ValidAudiences = new[] { "catalog-api", "shared-api" } }; ``` --- ## Pattern 3: Reference Token Introspection For APIs that validate reference tokens (opaque tokens) instead of JWTs: ```csharp builder.Services.AddAuthentication("Bearer") .AddOAuth2Introspection("Bearer", options => { options.Authority = "https://identity.example.com"; options.ClientId = "catalog-api"; options.ClientSecret = "api-secret"; }); ``` > Install the `Duende.AspNetCore.Authentication.JwtBearer` package which supports both JWT and reference token validation, switching automatically based on the token format. ### Combined JWT + Reference Token Support ```csharp builder.Services.AddAuthentication("Bearer") .AddJwtBearer("Bearer", options => { options.Authority = "https://identity.example.com"; options.MapInboundClaims = false; // The Duende JWT handler can forward to introspection for reference tokens options.ForwardDefaultSelector = Selector.ForwardReferenceToken("introspection"); }) .AddOAuth2Introspection("introspection", options => { options.Authority = "https://identity.example.com"; options.ClientId = "catalog-api"; options.ClientSecret = "api-secret"; }); ``` --- ## Pattern 4: Understanding Authentication Schemes ASP.NET Core uses named authentication schemes. Each scheme is handled by a specific handler. ### Default Schemes ```csharp builder.Services.AddAuthentication(options => { // Used for [Authorize] attribute and User.Identity options.DefaultScheme = "Cookies"; // Used when authentication is required (401 → redirect to login) options.DefaultChallengeScheme = "oidc"; // Used when access is denied (403) options.DefaultForbidScheme = "oidc"; // Used when signing in (setting the cookie after OIDC callback) options.DefaultSignInScheme = "Cookies"; // Used when signing out options.DefaultSignOutScheme = "oidc"; }); ``` ### The Authentication Flow ``` Request → [UseAuthentication] → Cookie handler reads cookie ├─ Valid cookie → User is authenticated └─ No cookie → User is anonymous [UseAuthorization] → [Authorize] attribute checks ├─ Authenticated → proceed └─ Not authenticated → Challenge └─ OIDC handler redirects to IdentityServer └─ User logs in → callback → cookie created ``` --- ## Pattern 5: Claim Type Mapping By default, the Microsoft OIDC handler remaps JWT claims to XML-based .NET claim types. This causes confusion: ### The Mapping Problem | JWT Claim | .NET Default Mapping | After `MapInboundClaims = false` | |-----------|---------------------|----------------------------------| | `sub` | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier` | `sub` | | `name` | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name` | `name` | | `role` | `http://schemas.microsoft.com/ws/2008/06/identity/claims/role` | `role` | | `email` | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress` | `email` | ### The Fix — Always Disable Mapping ```csharp // ✅ On the OIDC handler options.MapInboundClaims = false; // ✅ On the JWT Bearer handler options.MapInboundClaims = false; // ✅ Then tell ASP.NET Core which claims to use for Name and Role options.TokenValidationParameters = new TokenValidationParameters { NameClaimType = "name", // Strongly recommended to use "name" RoleClaimType = "role" // Strongly recommended to use "role" }; ``` > **Why this matters:** Without this, `User.FindFirst("sub")` returns `null` because the claim was renamed. You'd need to use the verbose XML URI instead. --- ## Pattern 6: OIDC Handler Events The OIDC handler exposes events for customizing the authentication pipeline: ```csharp .AddOpenIdConnect("oidc", options => { // ... other options ... options.Events = new OpenIdConnectEvents { // Customize the authorize request (e.g., add acr_values) OnRedirectToIdentityProvider = context => { context.ProtocolMessage.AcrValues = "tenant:myorg"; return Task.CompletedTask; }, // Handle tokens after successful authentication OnTokenValidated = context => { // Add custom claims to the identity var identity = context.Principal!.Identity as ClaimsIdentity; identity?.AddClaim(new Claim("app_version", "2.0")); return Task.CompletedTask; }, // Handle sign-out redirect OnRedirectToIdentityProviderForSignOut = context => { // Customize the logout redirect return Task.CompletedTask; }, // Handle failures OnRemoteFailure = context => { context.HandleResponse(); context.Response.Redirect("/error?message=" + Uri.EscapeDataString(context.Failure?.Message ?? "Unknown error")); return Task.CompletedTask; } }; }); ``` ### Common Event Use Cases | Event | Use Case | |-------|----------| | `OnRedirectToIdentityProvider` | Add `acr_values`, `login_hint`, or custom parameters | | `OnTokenValidated` | Transform claims, load additional user data | | `OnTokenResponseReceived` | Inspect raw token response | | `OnRemoteFailure` | Custom error handling for failed logins | | `OnSignedOutCallbackRedirect` | Custom post-logout redirect | --- ## Pattern 7: Sign-Out Proper sign-out must clear both the local cookie and the IdentityServer session: ```csharp // In a Razor Page or Controller app.MapGet("/logout", async (HttpContext ctx) => { // Signs out of both the cookie and IdentityServer await ctx.SignOutAsync("Cookies"); await ctx.SignOutAsync("oidc"); }); ``` ### The Sign-Out Flow ``` 1. Client calls SignOutAsync("Cookies") → clears local cookie 2. Client calls SignOutAsync("oidc") → redirects to IS /connect/endsession 3. IdentityServer clears its session 4. IdentityServer notifies other clients → front-channel or back-channel logout 5. IdentityServer redirects to PostLogoutRedirectUri ``` > **Important:** Calling only `SignOutAsync("Cookies")` without `SignOutAsync("oidc")` leaves the IdentityServer session active. The user will be silently re-authenticated on the next challenge. --- ## Pattern 8: Accessing Stored Tokens When `SaveTokens = true`, the access token, refresh token, and ID token are stored in the authentication cookie: ```csharp // In a controller or middleware var accessToken = await HttpContext.GetTokenAsync("access_token"); var refreshToken = await HttpContext.GetTokenAsync("refresh_token"); var idToken = await HttpContext.GetTokenAsync("id_token"); var expiresAt = await HttpContext.GetTokenAsync("expires_at"); // Use the access token to call an API httpClient.SetBearerToken(accessToken); ``` > **Better approach:** Use `Duende.AccessTokenManagement` (see `token-management` skill) which handles token refresh, caching, and rotation automatically instead of manually managing stored tokens. --- ## Pattern 9: mTLS (Certificate-Bound Tokens) with the OIDC Handler When IdentityServer issues certificate-bound tokens via mTLS (RFC 8705), the OIDC client must (1) present its client certificate on all back-channel calls and (2) target the mTLS endpoint aliases. The stock handler does neither on its own. ### Step 1 — Present the client certificate on back-channel calls Set `BackchannelHttpHandler` so code redemption, refresh, and userinfo calls run over a mutually-authenticated TLS channel. No client secret is needed — the certificate authenticates the client: ```csharp var clientCert = X509CertificateLoader.LoadPkcs12(File.ReadAllBytes("client.p12"), "password"); .AddOpenIdConnect("oidc", options => { options.Authority = "https://identity.example.com"; options.ClientId = "mtls.client"; // no ClientSecret — the certificate authenticates the client options.ResponseType = "code"; options.MapInboundClaims = false; options.SaveTokens = true; options.BackchannelHttpHandler = new SocketsHttpHandler { SslOptions = new SslClientAuthenticationOptions { ClientCertificates = new X509CertificateCollection { clientCert } } }; }); ``` ### Step 2 — Point the handler at `mtls_endpoint_aliases` The stock OIDC handler reads endpoints from standard discovery metadata and does **not** understand `mtls_endpoint_aliases` — it would call the non-mTLS `token_endpoint`. Wrap the standard configuration manager and rewrite the endpoints to their mTLS aliases: ```csharp public sealed class MtlsConfigurationManager : IConfigurationManager { private readonly ConfigurationManager _inner; public MtlsConfigurationManager(ConfigurationManager inner) => _inner = inner; public async Task GetConfigurationAsync(CancellationToken ct) { var config = await _inner.GetConfigurationAsync(ct); if (config.AdditionalData.TryGetValue("mtls_endpoint_aliases", out var raw) && raw is JsonElement aliases) { config.TokenEndpoint = aliases.GetProperty("token_endpoint").GetString(); config.IntrospectionEndpoint = aliases.GetProperty("introspection_endpoint").GetString(); config.DeviceAuthorizationEndpoint = aliases.GetProperty("device_authorization_endpoint").GetString(); // .NET 9+ auto-uses PAR when advertised — rewrite it too, or the handler // pushes to the non-mTLS PAR endpoint and the certificate binding is lost config.PushedAuthorizationRequestEndpoint = aliases.GetProperty("pushed_authorization_request_endpoint").GetString(); // revocation has no strongly-typed slot — keep the mTLS value in AdditionalData config.AdditionalData["revocation_endpoint"] = aliases.GetProperty("revocation_endpoint").GetString(); } return config; } public void RequestRefresh() => _inner.RequestRefresh(); } // Wire it onto the handler .AddOpenIdConnect("oidc", options => { // ... options from Step 1 ... options.ConfigurationManager = new MtlsConfigurationManager( new ConfigurationManager( $"{options.Authority}/.well-known/openid-configuration", new OpenIdConnectConfigurationRetriever(), new HttpDocumentRetriever { RequireHttps = true })); }); ``` > **PAR + mTLS on .NET 9+:** because the handler automatically uses PAR when the server advertises a `pushed_authorization_request_endpoint`, you **must** rewrite that endpoint to the mTLS alias as well. Otherwise the pushed authorization request goes to the non-mTLS endpoint and the certificate binding is lost. --- ## Common Pitfalls ### 1. Forgetting MapInboundClaims ```csharp // ❌ WRONG — Claims have XML URIs, User.FindFirst("sub") returns null .AddOpenIdConnect("oidc", options => { options.Authority = "https://identity.example.com"; // MapInboundClaims defaults to true }); // ✅ CORRECT .AddOpenIdConnect("oidc", options => { options.Authority = "https://identity.example.com"; options.MapInboundClaims = false; }); ``` ### 2. Missing UseAuthentication Before UseAuthorization ```csharp // ❌ WRONG — Authorization middleware can't see the authenticated user app.UseAuthorization(); app.UseAuthentication(); // Too late! // ✅ CORRECT — Authentication must come first app.UseAuthentication(); app.UseAuthorization(); ``` ### 3. Not Clearing Scopes Before Adding ```csharp // ❌ WRONG — Default scopes (openid, profile) are already added options.Scope.Add("openid"); // Duplicate! options.Scope.Add("profile"); // Duplicate! options.Scope.Add("api1"); // ✅ CORRECT — Clear defaults first options.Scope.Clear(); options.Scope.Add("openid"); options.Scope.Add("profile"); options.Scope.Add("api1"); ``` ### 4. Cookie Too Large (>4KB) When `SaveTokens = true` and many claims are included, the cookie can exceed browser limits: ```csharp // ✅ Solution 1: Use a server-side ITicketStore to move auth ticket out of the cookie. // Implement ITicketStore backed by IDistributedCache (e.g., Redis), then register it: builder.Services.AddStackExchangeRedisCache(options => { options.Configuration = "localhost:6379"; }); builder.Services.AddSingleton(); // your ITicketStore impl .AddCookie("Cookies", options => { // Wire the ITicketStore so the cookie only holds a session key, not the full ticket options.SessionStore = app.Services.GetRequiredService(); }); // Note: ITicketStore is in Microsoft.AspNetCore.Authentication.Cookies namespace. // There is no built-in DistributedSessionStore class — you must implement ITicketStore. // ✅ Solution 2: Filter claims stored in the cookie .AddOpenIdConnect("oidc", options => { options.ClaimActions.DeleteClaims("sid", "idp", "auth_time", "amr"); }); // ✅ Solution 3: Use Duende IdentityServer server-side sessions ``` ### 5. Redirect Loop After Login Usually caused by the cookie not being set due to SameSite restrictions: ```csharp // ✅ Check SameSite settings .AddCookie("Cookies", options => { options.Cookie.SameSite = SameSiteMode.Lax; // Not Strict for OIDC callbacks options.Cookie.SecurePolicy = CookieSecurePolicy.Always; }); ``` --- ## Resources - [ASP.NET Core Authentication — Microsoft Docs](https://learn.microsoft.com/aspnet/core/security/authentication/) - [OpenID Connect Handler — Microsoft Docs](https://learn.microsoft.com/aspnet/core/security/authentication/social/) - [JWT Bearer Handler — Microsoft Docs](https://learn.microsoft.com/aspnet/core/security/authentication/configure-jwt-bearer-authentication) - [Duende IdentityServer Quickstarts](https://docs.duendesoftware.com/identityserver/quickstarts/) - [OIDC Handler Events — Duende Docs](https://docs.duendesoftware.com/identityserver/fundamentals/openid-connect-events/)