Skip to content
Back to skills

Aspnetcore Authentication

ASecurity

ASP.NET Core authentication middleware configuration including OpenID Connect, JWT Bearer, cookie authentication, authentication schemes, challenge/forbid flows, and external identity provider integration.

  • 9 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added October 16, 2026
ai-agentsgodebuggingapisecurity

Works with

  • cli
  • api

Security analysis

A100/100

Scanned October 16, 2026

npx -y skills add DuendeSoftware/duende-skills --skill aspnetcore-authentication --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Aspnetcore Authentication?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Aspnetcore Authentication
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/duendesoftware-aspnetcore-authentication/badge)](https://www.skillsdirectory.com/skills/duendesoftware-aspnetcore-authentication)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
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/)

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.