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:

C#
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 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.

C#
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(options =>
    {
        options.LoginPath = "/login";
        options.ExpireTimeSpan = TimeSpan.FromHours(8);
    });

Signing a user in writes that cookie:

C#
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.

C#
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 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:

C#
[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:

C#
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));
});
C#
[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() before app.UseAuthentication() — authorization then evaluates against an empty, unauthenticated HttpContext.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 with Roles or Policy.
  • Storing a JWT in localStorage for 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 Unauthorized with 403 Forbidden — ASP.NET Core returns 401 when authentication itself failed or is missing, and 403 when 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.