Auth in ASP.NET Core: JWT, Cookies, Policies—What Fits?

Securing Your API the Right Way

You're building an e-commerce checkout flow. A user adds items to their cart, proceeds to payment, and places an order. At each step, your API needs to know who they are and what they're allowed to do. That's where authentication and authorization come in—one verifies identity, the other enforces permissions.

ASP.NET Core gives you multiple ways to handle this: cookie-based sessions for traditional web apps, JWT tokens for stateless APIs, and policy-based authorization for complex business rules. Each approach fits different scenarios. Cookies work well when the browser manages state, JWT shines in mobile and microservice architectures, and policies let you enforce fine-grained access control based on claims or custom logic.

You'll see how to implement each pattern, understand when to use which approach, and build a working minimal API that demonstrates JWT authentication with policy-based authorization. By the end, you'll know how to choose the right auth strategy for your application.

Authentication vs Authorization: Know the Difference

Authentication answers "Who are you?" while authorization answers "What can you do?" These concepts work together but serve distinct purposes. ASP.NET Core handles them through middleware that runs in your request pipeline.

When a request arrives, authentication middleware examines credentials (cookies, tokens, headers) and builds a ClaimsPrincipal representing the user. Authorization middleware then checks whether that user has permission to access the requested resource.

Program.cs
var builder = WebApplication.CreateBuilder(args);

// Add authentication and authorization services
builder.Services.AddAuthentication();
builder.Services.AddAuthorization();

var app = builder.Build();

// Authentication runs first - identifies the user
app.UseAuthentication();

// Authorization runs second - checks permissions
app.UseAuthorization();

app.MapGet("/public", () => "Anyone can access this");

app.MapGet("/protected", () => "Only authenticated users see this")
    .RequireAuthorization();

app.Run();

The order matters: authentication must run before authorization. Once the user is identified, authorization policies determine access. Without authentication, all users are anonymous and most authorization checks will fail.

JWT Bearer Tokens for Stateless APIs

JWT (JSON Web Token) authentication uses signed tokens that clients include in request headers. The server validates the signature and extracts claims without maintaining session state. This makes JWT ideal for APIs, mobile apps, and distributed systems where stateless operation matters.

A JWT contains three parts: header, payload (claims), and signature. The server signs the token using a secret key, and clients send it in the Authorization header as "Bearer {token}". Because the token carries all necessary information, the server doesn't need to query a database or cache on every request.

Program.cs - JWT authentication setup
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;

var builder = WebApplication.CreateBuilder(args);

var key = Encoding.UTF8.GetBytes("your-256-bit-secret-key-here-32chars!");

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = "your-api",
            ValidAudience = "your-app",
            IssuerSigningKey = new SymmetricSecurityKey(key)
        };
    });

builder.Services.AddAuthorization();

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

app.MapPost("/token", () =>
{
    var claims = new[]
    {
        new Claim(ClaimTypes.Name, "jane.doe"),
        new Claim(ClaimTypes.Email, "jane@example.com"),
        new Claim(ClaimTypes.Role, "Admin")
    };

    var tokenDescriptor = new SecurityTokenDescriptor
    {
        Subject = new ClaimsIdentity(claims),
        Expires = DateTime.UtcNow.AddHours(1),
        Issuer = "your-api",
        Audience = "your-app",
        SigningCredentials = new SigningCredentials(
            new SymmetricSecurityKey(key),
            SecurityAlgorithms.HmacSha256)
    };

    var handler = new JwtSecurityTokenHandler();
    var token = handler.CreateToken(tokenDescriptor);
    var jwt = handler.WriteToken(token);

    return Results.Ok(new { token = jwt });
});

app.MapGet("/api/data", (ClaimsPrincipal user) =>
{
    var name = user.Identity?.Name;
    return $"Secure data for {name}";
}).RequireAuthorization();

app.Run();

Clients call POST /token to get a JWT, then include it in subsequent requests as "Authorization: Bearer {token}". The server validates the signature and expiration on every request. If the token is compromised, it remains valid until expiration—there's no server-side revocation without additional infrastructure like token blacklisting.

Choosing the Right Approach

Choose cookies when you're building a traditional web application with server-side rendering. The browser handles cookies automatically, they're immune to CSRF when configured properly with anti-forgery tokens, and you can revoke sessions server-side instantly. Cookies work best when the client and API share the same domain.

Choose JWT when you need stateless APIs, mobile apps, or microservices. JWT tokens work across domains without CORS complications, scale horizontally without shared session stores, and let clients hold their own credentials. Trade-offs include larger request headers, no built-in revocation, and vulnerability to XSS if stored in localStorage.

For hybrid scenarios, use both: cookies for web pages and JWT for API routes. ASP.NET Core supports multiple authentication schemes simultaneously. Configure both schemes in Program.cs and use [Authorize(AuthenticationSchemes = "...")] to specify which scheme protects each endpoint.

If unsure, start with cookies for web apps and JWT for pure APIs. Monitor token expiration times and implement refresh token flows for JWTs that need longer-lived sessions. Keep cookies HttpOnly and Secure to prevent JavaScript access and ensure HTTPS-only transmission.

Policy-Based Authorization for Fine-Grained Control

Role-based authorization ([Authorize(Roles = "Admin")]) works for simple cases, but real applications need richer logic. Policy-based authorization lets you define custom requirements based on claims, business rules, or external data.

A policy consists of one or more requirements. Each requirement has a handler that evaluates whether the user satisfies it. You register policies in Program.cs and apply them with [Authorize(Policy = "PolicyName")] on controllers or endpoints.

MinimumAgeRequirement.cs
using Microsoft.AspNetCore.Authorization;

public class MinimumAgeRequirement : IAuthorizationRequirement
{
    public int MinimumAge { get; }

    public MinimumAgeRequirement(int minimumAge)
    {
        MinimumAge = minimumAge;
    }
}

public class MinimumAgeHandler : AuthorizationHandler<MinimumAgeRequirement>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        MinimumAgeRequirement requirement)
    {
        var dateOfBirth = context.User.FindFirst(c => c.Type == "DateOfBirth");

        if (dateOfBirth == null)
            return Task.CompletedTask;

        var age = DateTime.Today.Year -
            DateTime.Parse(dateOfBirth.Value).Year;

        if (age >= requirement.MinimumAge)
        {
            context.Succeed(requirement);
        }

        return Task.CompletedTask;
    }
}
Program.cs - Register policy
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("AtLeast18", policy =>
        policy.Requirements.Add(new MinimumAgeRequirement(18)));

    options.AddPolicy("AtLeast21", policy =>
        policy.Requirements.Add(new MinimumAgeRequirement(21)));
});

builder.Services.AddSingleton<IAuthorizationHandler, MinimumAgeHandler>();

// Apply to endpoints
app.MapGet("/adult-content", () => "Adults only")
    .RequireAuthorization("AtLeast18");

app.MapGet("/purchase-alcohol", () => "21+ required")
    .RequireAuthorization("AtLeast21");

Handlers can be synchronous or asynchronous, access external data, and combine multiple conditions. If all requirements in a policy succeed, access is granted. This pattern scales to complex scenarios like department-based permissions, time-based access, or resource-specific authorization.

Security and Safety Considerations

Never store sensitive secrets in your code. Use User Secrets during development and Azure Key Vault or environment variables in production. Rotate signing keys regularly and use strong keys (minimum 256 bits for HMAC-SHA256).

For cookies, set HttpOnly to prevent JavaScript access, Secure to enforce HTTPS, and SameSite to mitigate CSRF. For JWT, store tokens in memory or HttpOnly cookies—never localStorage, which is vulnerable to XSS. Always validate tokens server-side; never trust client claims without verification.

Implement token expiration and refresh token flows. Short-lived access tokens (15-60 minutes) limit exposure if compromised. Refresh tokens let clients obtain new access tokens without re-authenticating. Store refresh tokens securely and implement revocation for logout scenarios. See Microsoft's security best practices at https://learn.microsoft.com/en-us/aspnet/core/security/

Try It Yourself

Build a minimal API with JWT authentication and policy-based authorization. This example demonstrates token generation, claim-based policies, and protected endpoints.

Steps

  1. dotnet new webapi -n AuthDemo -minimal
  2. cd AuthDemo
  3. Replace Program.cs with the code below
  4. Update AuthDemo.csproj as shown
  5. dotnet run
  6. curl -X POST http://localhost:5000/token to get a JWT
  7. curl -H "Authorization: Bearer {token}" http://localhost:5000/api/admin
AuthDemo.csproj
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer"
                      Version="8.0.*" />
  </ItemGroup>
</Project>
Program.cs
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;

var builder = WebApplication.CreateBuilder(args);

var key = Encoding.UTF8.GetBytes("my-super-secret-key-32-characters!");

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = "auth-demo",
            ValidAudience = "auth-demo-users",
            IssuerSigningKey = new SymmetricSecurityKey(key)
        };
    });

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("AdminOnly", policy =>
        policy.RequireClaim(ClaimTypes.Role, "Admin"));
});

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

app.MapPost("/token", (string? role) =>
{
    var claims = new[]
    {
        new Claim(ClaimTypes.Name, "demo-user"),
        new Claim(ClaimTypes.Role, role ?? "User")
    };

    var tokenDescriptor = new SecurityTokenDescriptor
    {
        Subject = new ClaimsIdentity(claims),
        Expires = DateTime.UtcNow.AddMinutes(30),
        Issuer = "auth-demo",
        Audience = "auth-demo-users",
        SigningCredentials = new SigningCredentials(
            new SymmetricSecurityKey(key),
            SecurityAlgorithms.HmacSha256)
    };

    var handler = new JwtSecurityTokenHandler();
    var token = handler.WriteToken(handler.CreateToken(tokenDescriptor));

    return Results.Ok(new { token });
});

app.MapGet("/api/public", () => "Public endpoint - no auth required");

app.MapGet("/api/protected", (ClaimsPrincipal user) =>
    $"Hello, {user.Identity?.Name}!")
    .RequireAuthorization();

app.MapGet("/api/admin", () => "Admin-only data")
    .RequireAuthorization("AdminOnly");

app.Run();

What you'll see

POST /token returns a JWT. Use it to call protected endpoints. The /api/admin endpoint requires the "Admin" role claim. Call /token?role=Admin to generate an admin token, then access admin endpoints.

Production Integration Patterns

In production, separate token generation from your main API. Use Identity Server, Azure AD B2C, or Auth0 for token issuance. Your API validates tokens but doesn't create them—this separates authentication concerns from business logic.

Configure token validation to fetch signing keys from a JWKS endpoint rather than hardcoding them. This enables key rotation without deployment. For cookies, use distributed caching (Redis or SQL Server) when running multiple instances so session state stays consistent across servers.

Add logging and metrics for authentication events. Log failed attempts, token validation errors, and authorization denials. Track metrics like tokens issued per minute, average token lifetime, and policy evaluation latency. Use structured logging with correlation IDs to trace requests across services.

For microservices, implement token forwarding so downstream services can validate the same JWT. Use AddJwtBearer with options.ForwardDefaultSelector to pass tokens through your service mesh. Consider implementing API gateways that handle authentication once, then use mutual TLS for internal service communication.

Quick FAQ

When should I use JWT instead of cookies for authentication?

Use JWT for stateless APIs, mobile apps, or microservices where the client holds the token. Choose cookies for traditional web apps with server-side rendering where the browser manages session state automatically. JWT works across domains; cookies require additional CORS setup.

How do I secure JWT tokens from XSS and CSRF attacks?

Store JWTs in HttpOnly cookies to prevent XSS access, and use anti-forgery tokens for CSRF protection. For client-side storage, use secure, HttpOnly cookies or memory storage—never localStorage. Always validate tokens server-side and set short expiration times with refresh tokens.

What's the difference between authentication and authorization in ASP.NET Core?

Authentication verifies who the user is (identity), while authorization determines what they can do (permissions). ASP.NET Core handles authentication through schemes like JWT or cookies, then uses policies and claims to enforce authorization rules on controllers and endpoints.

Can I use multiple authentication schemes in one ASP.NET Core app?

Yes, ASP.NET Core supports multiple schemes simultaneously. Configure each scheme in Program.cs, then use [Authorize(AuthenticationSchemes = "...")] on endpoints. This lets you support both cookie auth for web pages and JWT for API routes in the same application.

How do policy-based authorization requirements work?

Policies define authorization rules using requirements and handlers. Create a requirement class, implement an AuthorizationHandler to evaluate it, then register both in DI. Apply with [Authorize(Policy = "PolicyName")]. Policies evaluate claims, roles, or custom business logic for fine-grained access control.

Back to Articles