← Files Duende SkillsARCHIVED FILE
skills/aspnetcore-authentication/SKILL.md
19.4 KB · Oct 5, 2026 · 18:31 UTC
---
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<OpenIdConnectConfiguration>
{
private readonly ConfigurationManager<OpenIdConnectConfiguration> _inner;
public MtlsConfigurationManager(ConfigurationManager<OpenIdConnectConfiguration> inner)
=> _inner = inner;
public async Task<OpenIdConnectConfiguration> 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<OpenIdConnectConfiguration>(
$"{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<ITicketStore, RedisTicketStore>(); // 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<ITicketStore>();
});
// 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/)
SHA-256: e0c245d7d4c16f8eebe713f1346b0f258dd8100368e590add3ff96f6949928ed