AI WisdomArchitecture & guides β†—
HT
How Things Work

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.

How It Works

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.

1
Request arrives β€” UseAuthentication middleware fires

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.

2
Token validation β€” signature, expiry, claims

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.

3
ClaimsPrincipal construction

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.

4
UseAuthorization middleware evaluates policies

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.

5
Policy evaluation β€” requirements and handlers

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).

6
401 vs 403 β€” the detail most devs miss

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

πŸ”IAuthenticationHandler

The interface JWT, Cookie, and OAuth handlers implement. AuthenticateAsync() verifies the credential and returns an AuthenticateResult containing the ClaimsPrincipal.

πŸ‘€ClaimsPrincipal

Represents the authenticated user. Contains one or more ClaimsIdentity objects (one per auth scheme). HttpContext.User is a ClaimsPrincipal.

πŸͺͺClaimsIdentity

A single identity from a single authentication scheme. A user can have multiple (e.g., cookie identity + external OAuth identity).

🏷️Claim

A key/value pair on a ClaimsIdentity. Examples: sub (subject), email, role, custom claims like tenant_id or audit_scope.

πŸ“‹IAuthorizationRequirement

A marker interface. Represents a single authorization condition (e.g., 'must have claim X'). Evaluated by a paired IAuthorizationHandler.

🎫JwtBearerDefaults

The default scheme name 'Bearer'. Used when registering and referencing the JWT authentication scheme in AddAuthentication().

⏱️ClockSkew

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.

βš–οΈPolicy vs Role vs Claim

Roles are just claims (ClaimTypes.Role). Policies compose multiple requirements. Prefer policies over [Authorize(Roles='X')] β€” they're testable and composable.

ASP.NET Core 9 β€” JWT + Policy Auth wiring
tsx
1// Program.cs β€” ASP.NET Core 9 auth wiring
2builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
3 .AddJwtBearer(options =>
4 {
5 options.TokenValidationParameters = new TokenValidationParameters
6 {
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 minutes
16 // Two microservices with drifted clocks can accept "expired" tokens silently
17 ClockSkew = TimeSpan.Zero,
18 };
19 });
20
21builder.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"));
26
27 // Default policy β€” every [Authorize] attribute must satisfy this
28 options.DefaultPolicy = new AuthorizationPolicyBuilder()
29 .RequireAuthenticatedUser()
30 .Build();
31});
32
33// In the pipeline, ORDER MATTERS
34app.UseAuthentication(); // Populates HttpContext.User
35app.UseAuthorization(); // Evaluates [Authorize] policies
36
37// Minimal API β€” requires "RequireAuditLog" policy
38app.MapPost("/audit-entries", CreateAuditEntry)
39 .RequireAuthorization("RequireAuditLog");
40
41// Controller β€” simple "must be authenticated"
42[ApiController]
43[Authorize]
44public class OrdersController : ControllerBase
45{
46 [AllowAnonymous] // Overrides [Authorize] on controller
47 [HttpGet("public")]
48 public IActionResult GetPublicInfo() => Ok("no token needed");
49}
πŸ’‘
Why This Matters

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

⚠Calling UseAuthorization() before UseAuthentication() β€” authorization runs against an unauthenticated (anonymous) principal. Every [Authorize] endpoint returns 401. The correct order is: UseRouting β†’ UseAuthentication β†’ UseAuthorization β†’ UseEndpoints.
⚠Default ClockSkew is 5 minutes. A token that expired at 2:00 PM is still accepted until 2:05 PM. In microservice architectures with token relay, this hides expiry bugs during development. Set ClockSkew = TimeSpan.Zero in production.
⚠Roles are just claims. [Authorize(Roles='Admin')] is equivalent to requiring a claim with type ClaimTypes.Role and value 'Admin'. Use policies instead β€” they're testable, composable, and don't scatter role strings across controllers.
⚠SameSite=Strict cookie auth breaks OAuth2 redirect flows. The browser won't send the correlation cookie on the cross-origin callback from the identity provider. Use SameSite=Lax or None (with Secure=true) for any app with external identity providers.
⚠Resource-based authorization cannot use [Authorize] attributes alone β€” the attribute executes before your action method fetches the entity. Inject IAuthorizationService and call AuthorizeAsync(user, resource, policy) inside the action after loading the resource.
⚠AddAuthentication() sets the default scheme. If you have multiple schemes (JWT + Cookie) and don't specify the default challenge scheme, the framework picks the first registered scheme β€” often not what you want. Always explicitly set DefaultAuthenticateScheme and DefaultChallengeScheme.
Real-World Use Cases

1Every API call started returning 401 after certificate rotation

Scenario

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.

Problem

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: ...'

Solution

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

Scenario

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.

Problem

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.

Solution

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

Scenario

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.

Problem

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.

Solution

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.