Authentication & Authorization In Depth
Cookie auth vs JWT bearer auth, [Authorize] with roles and policies, and a complete example.
Authentication vs authorization, restated
Authentication answers "who is making this request?" — authorization answers "is that identity allowed to do this?" ASP.NET Core keeps these cleanly separate: an authentication handler runs first and, if it succeeds, populates HttpContext.User with a ClaimsPrincipal (a bundle of claims describing the caller — a username, an email, a role); authorization then runs afterward and evaluates rules against that populated principal. Both need to be added to the middleware pipeline, in order:
app.UseAuthentication(); // populates HttpContext.User
app.UseAuthorization(); // checks HttpContext.User against [Authorize] rules
This track has two authentication schemes in everyday use, and picking between them is one of the first real architectural decisions an API makes.
Cookie authentication
Cookie authentication suits traditional, same-origin, server-rendered applications: after a successful login, the server issues an encrypted cookie the browser then sends automatically on every subsequent request, with no extra client-side code required.
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.LoginPath = "/login";
options.ExpireTimeSpan = TimeSpan.FromHours(8);
});
Signing a user in writes that cookie:
app.MapPost("/login", async (HttpContext context, string username) =>
{
var claims = new List<Claim>
{
new(ClaimTypes.Name, username),
new(ClaimTypes.Role, "Admin"),
};
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
await context.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(identity));
return Results.Ok();
});
JWT bearer authentication
JWT (JSON Web Token) bearer authentication is the standard choice for APIs consumed by single-page apps, mobile clients, or other services: instead of a cookie the browser attaches automatically, the client explicitly attaches a signed, self-contained token in an Authorization: Bearer <token> header on every request. The server validates the token's signature and claims without needing to look anything up server-side — no session store required.
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = "https://noalabs.example.com",
ValidateAudience = true,
ValidAudience = "noalabs-api",
ValidateLifetime = true,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!)),
};
});
Cookie vs JWT bearer
| Cookie authentication | JWT bearer authentication | |
|---|---|---|
| Where it lives | Browser cookie, sent automatically | Authorization header, attached manually by the client |
| State | Typically backed by a server-side session or an encrypted payload | Fully stateless — self-contained and signed |
| Typical clients | Server-rendered web apps, same-origin | SPAs, mobile apps, service-to-service calls |
| Revocation | Easy — delete the cookie/session immediately | Hard — must wait for expiry, or maintain a token blocklist |
| Main security risk | CSRF (needs antiforgery protection) | Token theft via XSS if stored insecurely (e.g., localStorage) |
[Authorize] with roles and policies
The plain [Authorize] attribute only checks that a request is authenticated — it says nothing about what that user can do. Adding Roles narrows it further:
[Authorize(Roles = "Admin")]
[HttpDelete("{id:int}")]
public IActionResult Delete(int id)
{
// only reached by an authenticated user with the "Admin" role
return NoContent();
}
Policies generalize this beyond a single role check into an arbitrary rule, registered once and referenced by name:
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("MinimumAge", policy =>
policy.RequireAssertion(context =>
context.User.HasClaim(c => c.Type == "DateOfBirth") &&
DateTime.Parse(context.User.FindFirst("DateOfBirth")!.Value).AddYears(18) <= DateTime.UtcNow));
});
[Authorize(Policy = "MinimumAge")]
[HttpPost("restricted-item-order")]
public IActionResult PlaceRestrictedOrder()
{
return Ok();
}
A policy can express anything a role check can (and considerably more — combining several claims, calling into custom logic), which is why larger applications tend to migrate from scattered Roles = "..." checks toward a small set of named, centrally-defined policies as authorization rules grow more specific than "does this user have role X."
Common mistakes
- Calling
app.UseAuthorization()beforeapp.UseAuthentication()— authorization then evaluates against an empty, unauthenticatedHttpContext.User, since authentication hasn't run yet to populate it. - Assuming a bare
[Authorize]checks roles or permissions — it only requires the caller to be authenticated at all; role/policy checks must be added explicitly withRolesorPolicy. - Storing a JWT in
localStoragefor a browser-based SPA, exposing it to theft via any XSS vulnerability — an httpOnly cookie (with CSRF protection) or in-memory storage is the safer pattern for browser clients. - Confusing
401 Unauthorizedwith403 Forbidden— ASP.NET Core returns401when authentication itself failed or is missing, and403when the caller is authenticated but doesn't satisfy an authorization rule.
Interview questions
Q: What's the practical difference between cookie authentication and JWT bearer authentication in ASP.NET Core?
Cookie authentication is sent automatically by the browser and suits same-origin, server-rendered apps, but is vulnerable to CSRF and needs server-side session state or an encrypted cookie payload. JWT bearer authentication is a self-contained, stateless token the client attaches manually to each request's Authorization header — the standard fit for SPAs, mobile clients, and service-to-service calls, at the cost of harder immediate revocation.
Q: What's the difference between [Authorize] alone and [Authorize(Roles = "Admin")]?
A bare [Authorize] only requires the request to be authenticated — any signed-in user passes. Adding Roles (or Policy) layers an authorization requirement on top, rejecting an authenticated user who doesn't hold the specified role, typically with a 403 Forbidden.
Q: Why does middleware order matter for UseAuthentication() and UseAuthorization()?
UseAuthentication() must run first because it's what populates HttpContext.User from the incoming credentials (a cookie or bearer token); UseAuthorization() evaluates [Authorize] rules against that same HttpContext.User. Registering them in the wrong order means authorization checks run against a user that was never actually populated.