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.

Syntaxbuilder.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

OperationWho calls itTypical result
AuthenticateUseAuthentication, for the default authenticate schemeSets HttpContext.User or leaves it anonymous
ChallengeAuthorization, when the user is not authenticatedBearer: 401 + WWW-Authenticate; cookie: redirect to login
ForbidAuthorization, when the user is authenticated but not allowed403 (cookie: redirect to AccessDenied for pages)
SignIn / SignOutYour login and logout codeCookie 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

Try it yourself
Loading editor…
Press Run to execute the code.

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.

Example · csharp
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>.

Example · csharp
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.

Example · csharp
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

  • Be the first to comment on this lesson.