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.
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.
Cookie-Based Authentication for Web Apps
Cookie authentication stores a session identifier in an encrypted browser cookie. The server maintains session state and validates the cookie on each request. This pattern works best for server-rendered web applications where the browser automatically sends cookies with every request.
ASP.NET Core encrypts the authentication cookie using data protection APIs, so sensitive claims stay secure. When a user logs in, you create a ClaimsPrincipal with their identity and claims, then sign them in. The framework handles cookie creation, validation, and renewal automatically.
using Microsoft.AspNetCore.Authentication.Cookies;
using System.Security.Claims;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.LoginPath = "/login";
options.ExpireTimeSpan = TimeSpan.FromHours(2);
options.SlidingExpiration = true;
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/login", async (HttpContext context) =>
{
var claims = new List<Claim>
{
new Claim(ClaimTypes.Name, "john.doe"),
new Claim(ClaimTypes.Email, "john@example.com"),
new Claim(ClaimTypes.Role, "User")
};
var identity = new ClaimsIdentity(claims,
CookieAuthenticationDefaults.AuthenticationScheme);
var principal = new ClaimsPrincipal(identity);
await context.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
principal);
return Results.Ok("Logged in successfully");
});
app.MapGet("/profile", (ClaimsPrincipal user) =>
{
var name = user.FindFirst(ClaimTypes.Name)?.Value;
return $"Welcome, {name}!";
}).RequireAuthorization();
app.Run();
The cookie persists across requests until it expires or the user logs out. Sliding expiration renews the cookie if the user stays active, preventing timeout during long sessions. This approach requires server-side session validation on every request, which limits stateless scalability but simplifies client code.
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.
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.
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;
}
}
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
- dotnet new webapi -n AuthDemo -minimal
- cd AuthDemo
- Replace Program.cs with the code below
- Update AuthDemo.csproj as shown
- dotnet run
- curl -X POST http://localhost:5000/token to get a JWT
- curl -H "Authorization: Bearer {token}" http://localhost:5000/api/admin
<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>
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.