Authentication & Authorization in ASP.NET Core
How JWT tokens, cookies, and claims flow through the auth pipeline β and why AuthN and AuthZ are different things.
ASP.NET Core separates authentication (who are you?) from authorization (what can you do?). These are two distinct middleware stages. A request first goes through UseAuthentication(), which populates HttpContext.User with a ClaimsPrincipal. Then UseAuthorization() evaluates policy requirements against that principal. Confusing them β or registering them in the wrong order β is the source of a large class of production auth bugs.
Every request first hits the authentication middleware. It inspects the Authorization header (or cookie, or custom scheme). If a Bearer token is present, the JwtBearerHandler is invoked. The middleware calls IAuthenticationService.AuthenticateAsync(), which calls the registered IAuthenticationHandler.
JwtBearerHandler decodes the JWT. It validates: the signature (using the IssuerSigningKey), the issuer and audience, and the expiry (nbf/exp claims). A single validation failure throws an exception. The ClockSkew parameter gives leeway for clock drift β default 5 minutes, which can hide expired-token bugs in local dev.
If validation passes, the handler builds a ClaimsIdentity from the JWT payload, then wraps it in a ClaimsPrincipal. This principal is assigned to HttpContext.User. From this point, User.Identity.IsAuthenticated is true, and User.Claims contains all JWT claims.
The authorization middleware runs AFTER authentication. It reads the [Authorize] attribute (or RequireAuthorization() on minimal APIs) to determine which policy to evaluate. It calls IAuthorizationService.AuthorizeAsync() with the current ClaimsPrincipal and the policy.
Each AuthorizationPolicy contains one or more IAuthorizationRequirement objects. The IAuthorizationHandler for each requirement inspects the ClaimsPrincipal and calls context.Succeed() or context.Fail(). All requirements must pass. If any fail, the framework returns 403 Forbidden (not 401 Unauthorized β they mean different things).
401 Unauthorized means: no valid identity was established (no token, or token invalid). 403 Forbidden means: identity was established, but it lacks the required permission. Calling UseAuthentication() without UseAuthorization() in the correct order causes 401s even when a valid token is provided.
Key Concepts
The interface JWT, Cookie, and OAuth handlers implement. AuthenticateAsync() verifies the credential and returns an AuthenticateResult containing the ClaimsPrincipal.
Represents the authenticated user. Contains one or more ClaimsIdentity objects (one per auth scheme). HttpContext.User is a ClaimsPrincipal.
A single identity from a single authentication scheme. A user can have multiple (e.g., cookie identity + external OAuth identity).
A key/value pair on a ClaimsIdentity. Examples: sub (subject), email, role, custom claims like tenant_id or audit_scope.
A marker interface. Represents a single authorization condition (e.g., 'must have claim X'). Evaluated by a paired IAuthorizationHandler.
The default scheme name 'Bearer'. Used when registering and referencing the JWT authentication scheme in AddAuthentication().
Leeway added to JWT expiry validation. Default: 5 minutes. Set to TimeSpan.Zero in production to get exact expiry behavior. The default silently accepts tokens that expired 5 minutes ago.
Roles are just claims (ClaimTypes.Role). Policies compose multiple requirements. Prefer policies over [Authorize(Roles='X')] β they're testable and composable.
1// Program.cs β ASP.NET Core 9 auth wiring2builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)3 .AddJwtBearer(options =>4 {5 options.TokenValidationParameters = new TokenValidationParameters6 {7 ValidateIssuer = true,8 ValidateAudience = true,9 ValidateLifetime = true,10 ValidateIssuerSigningKey = true,11 ValidIssuer = builder.Configuration["Jwt:Issuer"],12 ValidAudience = builder.Configuration["Jwt:Audience"],13 IssuerSigningKey = new SymmetricSecurityKey(14 Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!)),15 // β οΈ this is the footgun β default ClockSkew is 5 minutes16 // Two microservices with drifted clocks can accept "expired" tokens silently17 ClockSkew = TimeSpan.Zero,18 };19 });2021builder.Services.AddAuthorization(options =>22{23 // Policy-based auth β NOT the same as [Authorize(Roles = "Admin")]24 options.AddPolicy("RequireAuditLog", policy =>25 policy.RequireClaim("audit_scope", "write"));2627 // Default policy β every [Authorize] attribute must satisfy this28 options.DefaultPolicy = new AuthorizationPolicyBuilder()29 .RequireAuthenticatedUser()30 .Build();31});3233// In the pipeline, ORDER MATTERS34app.UseAuthentication(); // Populates HttpContext.User35app.UseAuthorization(); // Evaluates [Authorize] policies3637// Minimal API β requires "RequireAuditLog" policy38app.MapPost("/audit-entries", CreateAuditEntry)39 .RequireAuthorization("RequireAuditLog");4041// Controller β simple "must be authenticated"42[ApiController]43[Authorize]44public class OrdersController : ControllerBase45{46 [AllowAnonymous] // Overrides [Authorize] on controller47 [HttpGet("public")]48 public IActionResult GetPublicInfo() => Ok("no token needed");49}
Authentication and authorization are foundational to API security. Getting the pipeline order wrong, misconfiguring ClockSkew, or using role-based [Authorize] attributes instead of policies leads to security holes and hard-to-debug 401/403 responses in production. Understanding the claim β principal β policy chain makes these bugs immediately obvious.
Common Pitfalls
1Every API call started returning 401 after certificate rotation
Our API gateway rotated the JWT signing certificate at 2 AM. By 2:05 AM, every microservice was returning 401 for valid user sessions. PagerDuty fired 40 alerts in 3 minutes.
The JwtBearerOptions.IssuerSigningKey was hardcoded as a SymmetricSecurityKey in appsettings.json. When the cert rotated, the services were still validating tokens against the OLD key. There was no key rollover window. Every token validation failed with: 'IDX10503: Signature validation failed. Keys tried: ...'
Switch to JwksUri-based validation: options.Authority = identityServerUrl. ASP.NET Core's JwtBearerHandler will fetch the JWKS endpoint and cache signing keys. When a validation fails with a key-mismatch, the handler auto-refreshes the JWKS cache and retries once β giving a zero-downtime key rotation path.
Takeaway: Hardcoding IssuerSigningKey is a time bomb. Use Authority + JWKS auto-discovery. The JwtBearerHandler has built-in key refresh logic, but only if you let it fetch keys dynamically.
2OAuth2 redirect loop with SameSite=Strict cookies
We deployed a new MVC app with cookie authentication. The login flow worked perfectly in local dev. In production, users were caught in an infinite redirect loop β login page β identity provider β back to app β login page again, forever.
The .AddCookie() middleware defaults changed in .NET 6+ to SameSite=Lax. But our ops team had deployed behind a load balancer that stripped the HTTPS scheme, causing the correlation cookie (set during the OAuth2 code flow) to be rejected. With SameSite=Strict, the browser refuses to send the cookie on the cross-origin redirect from the identity provider, so the OAuth state validation fails.
Set options.Cookie.SameSite = SameSiteMode.None with options.Cookie.SecurePolicy = CookieSecurePolicy.Always. Also configure the ForwardedHeaders middleware BEFORE cookie auth so ASP.NET Core sees the correct HTTPS scheme from the X-Forwarded-Proto header. Without this, cookie Secure flag is never set and SameSite=None cookies are rejected by browsers.
Takeaway: Cookie SameSite policy and HTTPS scheme forwarding interact. In any environment behind a reverse proxy, configure UseForwardedHeaders() as the very first middleware before auth. SameSite=None requires Secure=true, which requires the app to know it's behind HTTPS.
3403 instead of 404 leaking resource existence
A security audit found that our API was returning 403 Forbidden when an authenticated user hit /api/documents/{id} for a document belonging to another user. This confirmed the document existed β a resource enumeration vulnerability.
The controller had [Authorize] at the class level and a resource-based authorization check inside the action method. When the ownership check failed, we threw a 403. An attacker could probe IDs: 403 = exists but not yours, 404 = doesn't exist at all.
Use IAuthorizationService in the action, but return 404 on authorization failure for sensitive resources: var result = await _authorizationService.AuthorizeAsync(User, document, 'DocumentOwner'); if (!result.Succeeded) return NotFound(); This requires resource-based authorization with a custom IAuthorizationHandler that receives the document as the resource parameter.
Takeaway: For sensitive resources, return 404 on authorization failure, not 403. Use IAuthorizationService with resource-based policies rather than [Authorize] attributes. The attribute can't inspect the resource because it runs before the action body fetches the entity.