Authentication Schemes and Handlers
Explain how schemes, handlers, the default scheme, challenge and forbid turn a request into a ClaimsPrincipal and a 401 or 403.
builder.Services.AddAuthentication("Bearer")
.AddScheme<AuthenticationSchemeOptions, MyHandler>("MyScheme", null);
app.UseAuthentication();
app.UseAuthorization();Authentication in ASP.NET Core answers one question: who sent this request? AddAuthentication() registers IAuthenticationService and a scheme provider; each AddJwtBearer, AddCookie or AddScheme<TOptions, THandler> call adds a scheme: a name, a handler type and a named options instance. Handlers are created per request, and a handler caches its authenticate result, so calling AuthenticateAsync twice in one request does the work once.
The five operations
| Operation | Who calls it | Typical result |
|---|---|---|
| Authenticate | UseAuthentication, for the default authenticate scheme | Sets HttpContext.User or leaves it anonymous |
| Challenge | Authorization, when the user is not authenticated | Bearer: 401 + WWW-Authenticate; cookie: redirect to login |
| Forbid | Authorization, when the user is authenticated but not allowed | 403 (cookie: redirect to AccessDenied for pages) |
| SignIn / SignOut | Your login and logout code | Cookie written or deleted |
Interviewers ask for exactly this distinction: 401 means "I do not know who you are, authenticate", 403 means "I know who you are and the answer is no". Retrying a 403 with the same credentials never helps; retrying a 401 after refreshing a token might.
The default scheme
DefaultScheme is the fallback for DefaultAuthenticateScheme, DefaultChallengeScheme, DefaultForbidScheme, DefaultSignInScheme and DefaultSignOutScheme. When exactly one scheme is registered it becomes the default automatically. The production failure: a team adds an API key scheme next to JWT bearer, the automatic default disappears, UseAuthentication authenticates nobody, and the first protected request throws InvalidOperationException (no DefaultChallengeScheme found), which the client sees as a 500. Always name the default with AddAuthentication(JwtBearerDefaults.AuthenticationScheme) and select other schemes per endpoint or with a policy scheme.
Middleware order
WebApplication adds UseAuthentication and UseAuthorization for you when the services are registered, but call them explicitly once you have other middleware whose order matters: after UseRouting (if you call it) and UseCors, before a rate limiter that partitions by user, and before the endpoints. UseAuthorization before UseAuthentication evaluates every policy against an anonymous user, so every protected endpoint returns 401 even with a valid token.
ClaimsPrincipal
The result is a ClaimsPrincipal holding one or more ClaimsIdentity objects. Identity.IsAuthenticated is true only when the identity has a non-empty AuthenticationType: new ClaimsIdentity(claims) produces an unauthenticated identity, and authorization rejects it with 401 even though the claims are there. Identity.Name reads the claim named by NameClaimType and IsInRole reads RoleClaimType, which is why claim type mapping matters for JWTs.
Example
When to use it
- Return 401 with WWW-Authenticate to a mobile app whose access token expired, so it refreshes the token, and 403 to a customer calling a support-only refund endpoint.
- Add a partner API key scheme to an orders API that already uses JWT bearer without breaking existing clients.
- Serve /orders/mine to both a browser session cookie and a mobile bearer token through a policy scheme.
More examples
Choosing JWT or cookie per request with a policy scheme
A policy scheme is the clean way to serve two kinds of client on the same endpoints. It forwards authenticate, challenge and forbid to the real scheme chosen by <code>ForwardDefaultSelector</code>, so a request with a <code>Bearer</code> header goes to JWT bearer and everything else, including a mobile call that has no token yet, gets the cookie scheme's challenge: a 401 without <code>WWW-Authenticate: Bearer</code> on API endpoints, a login redirect on pages. A client whose bearer token is expired or invalid does reach JWT bearer and gets its 401 with <code>WWW-Authenticate</code>. If tokenless API clients must get a bearer challenge, select on the path (for example <code>/api/*</code> goes to bearer) instead of the header. Keep the selector cheap and deterministic, since it runs on every request, and make sure every scheme it can select is acceptable for every endpoint behind it.
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.JwtBearer;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddAuthentication("JwtOrCookie")
.AddPolicyScheme("JwtOrCookie", "Bearer token or session cookie", options =>
{
// A policy scheme has no handler of its own; it forwards every operation.
options.ForwardDefaultSelector = context =>
{
string authorization = context.Request.Headers.Authorization.ToString();
return authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
? JwtBearerDefaults.AuthenticationScheme
: CookieAuthenticationDefaults.AuthenticationScheme;
};
})
.AddJwtBearer(options =>
{
options.Authority = builder.Configuration["Auth:Authority"];
options.Audience = "orders-api";
})
.AddCookie(options => options.Cookie.Name = "__Host-shop");
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
// Mobile clients send a bearer token, the web front end sends its cookie: one endpoint serves both.
app.MapGet("/orders/mine", () => TypedResults.Ok(new[] { new { id = 1001, total = 59.90m } }))
.RequireAuthorization();
app.Run();Pitfall: claims present, still 401
This is the most common bug in hand-written handlers and test authentication schemes. The identity built without an authentication type still answers <code>FindFirst</code> and even <code>IsInRole</code>, so unit tests on claims pass, but <code>RequireAuthorization()</code> sees an anonymous user and returns 401. Pass the scheme name as the authentication type, and pass <code>nameType</code> and <code>roleType</code> when your claims use short names such as <code>name</code> and <code>role</code>.
using System.Security.Claims;
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, "cust-42"),
new Claim(ClaimTypes.Name, "[email protected]"),
new Claim(ClaimTypes.Role, "customer"),
};
// Wrong: no authentication type, so IsAuthenticated is false and authorization challenges.
var broken = new ClaimsPrincipal(new ClaimsIdentity(claims));
// Right: any non-empty authentication type marks the identity as authenticated.
var fixedIdentity = new ClaimsIdentity(claims, authenticationType: "ApiKey",
nameType: ClaimTypes.Name, roleType: ClaimTypes.Role);
var working = new ClaimsPrincipal(fixedIdentity);
Console.WriteLine($"broken: authenticated={broken.Identity!.IsAuthenticated}, name={broken.Identity.Name}");
Console.WriteLine($"working: authenticated={working.Identity!.IsAuthenticated}, name={working.Identity.Name}, " +
$"customer={working.IsInRole("customer")}");Two schemes with an explicit default and a per-group scheme
<code>AddAuthenticationSchemes</code> on a policy makes authorization authenticate with exactly those schemes and build the user from them, ignoring <code>HttpContext.User</code> from the default scheme. The challenge for that policy also goes to the partner scheme, so a partner without a key gets the partner scheme's 401, not a bearer challenge. Without the explicit default passed to <code>AddAuthentication</code>, adding the second scheme would leave the default unset.
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) // explicit, survives adding schemes
.AddJwtBearer(options =>
{
options.Authority = builder.Configuration["Auth:Authority"];
options.Audience = "orders-api";
})
.AddScheme<AuthenticationSchemeOptions, PartnerKeyHandler>("PartnerKey", null);
builder.Services.AddAuthorizationBuilder()
.AddPolicy("Partner", policy => policy
.AddAuthenticationSchemes("PartnerKey")
.RequireAuthenticatedUser());
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/orders/{id:int}", (int id) => TypedResults.Ok(new { id })).RequireAuthorization();
// Only this group authenticates with the partner key; a bearer token is ignored here.
var partner = app.MapGroup("/partner").RequireAuthorization("Partner");
partner.MapGet("/stock/{sku}", (string sku) => TypedResults.Ok(new { sku, available = 17 }));
app.Run();
sealed class PartnerKeyHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
// Real validation is in the API keys lesson; this stub only shows the wiring.
if (!Request.Headers.ContainsKey("X-Api-Key"))
return Task.FromResult(AuthenticateResult.NoResult());
var identity = new ClaimsIdentity([new Claim(ClaimTypes.Name, "partner-acme")], Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name)));
}
}
Discussion