Authentication & Authorization
Authentication in ASP.NET Core -- JWT bearer and cookie schemes, ASP.NET Core Identity, and the policy, requirement, and handler authorization model
Authentication & Authorization
ASP.NET Core draws a clean line between two concerns that are easy to conflate. Authentication is the question of who you are -- a handler inspects an incoming credential (a bearer token, a cookie, an OIDC session) and, if it is valid, establishes a ClaimsPrincipal on the request. Authorization is the separate question of what that principal is allowed to do -- policies evaluate the principal's claims against rules and return a yes or a no. The two are wired into the request through middleware, and the single most common reason "nothing works" is getting the two-line ordering wrong: UseAuthentication() must run before UseAuthorization(), because you cannot authorize a principal you have not yet established.
Authentication Schemes
Authentication in ASP.NET Core is built around the idea of a named scheme. You register one or more schemes -- JWT bearer, cookie, OAuth, OpenID Connect -- and each is backed by a handler that knows how to read and validate its kind of credential. When you call AddAuthentication("Bearer") you name a default scheme, which is the one used whenever a request, an [Authorize] attribute, or a sign-in call does not specify one explicitly. A single app can register several schemes and select between them per endpoint, which is how a service can accept both a cookie for its browser UI and a bearer token for its API.
For an API the common choice is JWT bearer. The handler pulls the token off the Authorization header and validates it against the parameters you supply -- and these parameters are the whole security boundary, so they are worth reading line by line.
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = config["Jwt:Issuer"],
ValidateAudience = true,
ValidAudience = config["Jwt:Audience"],
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(keyBytes),
ValidAlgorithms = ["HS256"],
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30)
};
});
app.UseAuthentication();
app.UseAuthorization();Two of these lines do more than they appear to. ValidAlgorithms is an allow-list, and setting it is what stops an attacker from handing you a token signed with alg: none or with a downgraded algorithm and having the handler accept it -- never leave the algorithm open. ClockSkew defaults to a generous five minutes, which means a token can be honoured up to five minutes past its stated expiry; tightening it to thirty seconds keeps short-lived tokens actually short-lived. The issuer and audience checks ensure a token minted for some other service, or by some other issuer, is rejected even if its signature is valid.
Cookie Authentication
For server-rendered apps -- Razor Pages, MVC, Blazor Server -- a cookie is the natural credential. After the user proves who they are, you build a ClaimsPrincipal and call SignInAsync, which serializes the principal into an encrypted, tamper-proof cookie that the browser returns on every subsequent request. The handler decrypts it back into a principal, so the identity effectively rides inside the cookie rather than in a server-side session table.
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.SameSite = SameSiteMode.Lax;
options.ExpireTimeSpan = TimeSpan.FromHours(8);
options.SlidingExpiration = true;
});
// at the login endpoint, after verifying credentials
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, user.Id),
new(ClaimTypes.Name, user.Email),
new(ClaimTypes.Role, "admin")
};
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
await HttpContext.SignInAsync(new ClaimsPrincipal(identity));The cookie flags are not optional hardening -- they are the security model. HttpOnly keeps JavaScript from reading the cookie, blunting XSS-based theft; SecurePolicy = Always refuses to send it over plain HTTP; SameSite constrains cross-site sending. Because the principal lives in an encrypted cookie, this scheme is stateful-ish without a session store, but it pairs naturally with antiforgery tokens: a browser will attach the cookie to any request including a forged cross-site one, so any state-changing endpoint needs the antiforgery check that bearer-token APIs do not.
ASP.NET Core Identity
ASP.NET Core Identity is the first-party membership system -- the full machinery for owning your users. It gives you user and role stores (usually backed by EF Core), password hashing, account lockout, email and phone confirmation, two-factor authentication, and external login linking, all behind UserManager and SignInManager. You reach for it when you own the user database: when your app handles registration, login, password reset, and the lifecycle of accounts itself.
builder.Services.AddIdentityCore<AppUser>(options =>
{
options.Password.RequiredLength = 12;
options.Lockout.MaxFailedAccessAttempts = 5;
options.SignIn.RequireConfirmedEmail = true;
})
.AddRoles<IdentityRole>()
.AddEntityFrameworkStores<AppDbContext>();The decision worth making deliberately is whether to use it at all. If you delegate identity to an external IdP -- Entra ID, Auth0, Keycloak -- over OpenID Connect, that provider owns registration, passwords, and MFA, and your app simply consumes the tokens it issues; pulling in Identity then duplicates a system you have chosen not to run. And note what Identity is and is not: it is the storage and credential layer, not a token issuer. Once a user is authenticated through it you still mint your own JWT for the API, or sign them into a cookie for the UI, on top -- Identity verifies the password; the scheme carries the session.
Authorization: Roles, Claims, and Policies
Authorization has a natural progression from blunt to precise. A bare [Authorize] requires only that the request be authenticated -- any valid principal passes. [Authorize(Roles = "Admin")] is classic role-based access control: the principal must carry a matching role claim. Roles are coarse, though, and string-matching them across a codebase ages badly. The recommended approach is policies -- named, centrally registered rules that you can compose from claim and role requirements, reuse across endpoints, and unit-test in isolation.
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("Over18", p => p.RequireClaim("age_verified", "true"));
options.AddPolicy("Admin", p => p.RequireRole("admin"));
});
app.MapDelete("/users/{id}", DeleteUser).RequireAuthorization("Admin");A policy is declared once and applied by name in both endpoint styles, which is most of why it scales: on a controller or action it is [Authorize("Admin")], and on a minimal API it is .RequireAuthorization("Admin"). The rule lives in one place, so tightening "Admin" later changes every endpoint at once rather than sending you on a hunt for scattered role strings.
Requirements and Handlers (custom policies)
When a policy needs logic beyond "has this claim" -- a computation, a lookup, a comparison -- you split it into a requirement (a marker carrying the policy's parameters) and a handler (the code that decides whether the requirement is met). The requirement implements IAuthorizationRequirement; the handler derives from AuthorizationHandler<TRequirement> and either calls context.Succeed(requirement) or simply returns without succeeding.
public class MinimumAgeRequirement(int minimumAge) : IAuthorizationRequirement
{
public int MinimumAge { get; } = minimumAge;
}
public class MinimumAgeHandler : AuthorizationHandler<MinimumAgeRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context, MinimumAgeRequirement requirement)
{
var dobClaim = context.User.FindFirst("date_of_birth");
if (dobClaim is null || !DateTime.TryParse(dobClaim.Value, out var dob))
return Task.CompletedTask;
var age = DateTime.Today.Year - dob.Year;
if (dob > DateTime.Today.AddYears(-age)) age--;
if (age >= requirement.MinimumAge)
context.Succeed(requirement);
return Task.CompletedTask;
}
}You wire the requirement into a policy and register the handler in DI. Handlers are usually stateless, so a singleton is the right lifetime unless the handler depends on a scoped service such as a DbContext.
builder.Services.AddAuthorization(options =>
options.AddPolicy("Over18", p => p.AddRequirements(new MinimumAgeRequirement(18))));
builder.Services.AddSingleton<IAuthorizationHandler, MinimumAgeHandler>();A subtlety worth knowing: not calling context.Succeed is not the same as actively failing. A requirement can have several handlers, and the requirement passes if any one of them succeeds -- so a handler that does not apply simply returns and lets another handler weigh in, rather than vetoing the request.
Resource-Based Authorization
Some decisions cannot be made from claims alone because they depend on the specific entity in play -- can this user edit this document, does this row belong to their tenant. You cannot answer that at the attribute level, because the attribute runs before the endpoint has loaded the data. The pattern is to load the resource first, then ask the IAuthorizationService to evaluate a requirement against the user and that resource together.
public class DocumentOwnerHandler
: AuthorizationHandler<SameOwnerRequirement, Document>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
SameOwnerRequirement requirement,
Document document)
{
var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId is not null && document.OwnerId == userId)
context.Succeed(requirement);
return Task.CompletedTask;
}
}The call site loads the document, then evaluates the policy against it before allowing the edit to proceed.
app.MapPut("/documents/{id}", async (
string id, UpdateDoc body, ClaimsPrincipal user,
IDocumentStore store, IAuthorizationService authz, CancellationToken ct) =>
{
var document = await store.GetAsync(id, ct);
if (document is null) return Results.NotFound();
var result = await authz.AuthorizeAsync(user, document, new SameOwnerRequirement());
if (!result.Succeeded) return Results.Forbid();
await store.UpdateAsync(document, body, ct);
return Results.NoContent();
});The point of routing this through AuthorizationService rather than an inline if (document.OwnerId == userId) is that the ownership check stays where authorization decisions belong -- in a handler, named, reusable, and testable -- instead of being scattered as ad-hoc conditionals through your endpoint logic where the next developer will forget one.
Sharp Edges
The failures here are reliably the same handful. Middleware ordering is first: UseAuthorization must come after UseAuthentication, and both must sit after UseRouting and before the endpoints they protect -- misplace them and authorization silently never runs. Learn to read 401 versus 403: a 401 means unauthenticated (no valid principal was established, so re-authenticate), while a 403 means authenticated but forbidden (the principal is known and simply not allowed), and confusing them sends you debugging the wrong layer. The ClockSkew default of five minutes routinely masks expiry and clock-drift bugs by silently accepting stale tokens -- tighten it and they surface. Forgetting to constrain ValidAlgorithms leaves the door open to algorithm-substitution attacks. And remember that authorization attributes compose with an override: an [Authorize] on a controller is undone by an [AllowAnonymous] on one of its actions, which is occasionally what you want and occasionally a hole someone left open.