Auth in .NET: JWT vs Cookie, OIDC, Refresh Tokens
How to pick between JWT and cookie auth in ASP.NET Core, when to outsource to OIDC (Duende, OpenIddict, Keycloak), and how refresh-token rotation actually works.
Table of contents
- When does the auth choice actually matter?
- What numbers should I budget for the auth tier?
- What does the minimal auth architecture look like?
- What is the .NET 10 wiring for cookie auth?
- What is the .NET 10 wiring for JWT?
- What failure modes does the auth tier introduce?
- When should you skip OIDC entirely?
- Where should you go from here?
Authentication looks simple - "log the user in" - until you scale. Then it is suddenly about cookie security flags, refresh-token rotation, JWT validation, OIDC discovery documents, and which one of those a bored intern can break in production. This chapter gives you the decision tree for .NET, with the failure modes that decide each branch.
When does the auth choice actually matter?
Three signals.
More than one client. A single browser app gets cookies for free. The moment you add a mobile app, a desktop app, or a public API, the cookie story breaks - mobile apps cannot use cookies the same way - and JWT plus refresh becomes attractive.
More than one service. A monolith validates session in one place. A microservice fleet either calls the auth service on every request (latency + coupling) or accepts a self-contained token (JWT). The second option scales further.
Multiple apps sharing identity. Customer-facing app + internal admin + partner portal, all on the same user store. Custom auth becomes a maintenance burden; OIDC was built for exactly this case.
If none of these are true, the simplest cookie auth is the right choice and stays that way for years.
What numbers should I budget for the auth tier?
Strategy Validation cost Storage Revocation
Cookie + DB session ~1-3 ms (DB) server-side immediate
Cookie + Redis sess. ~0.5 ms (cache) Redis immediate
JWT (no DB) ~50 µs (signature) stateless up to TTL
JWT + denylist Redis ~0.5 ms Redis immediate
OIDC (Duende) ~50 µs validate issuer-side token rotation
JWT's "no database hit per request" advantage is real but small at modern scale - 1 ms of database latency at 10K QPS is 10 CPU-seconds of wall clock. The bigger trade-off is revocation: a stolen JWT is valid until expiry unless you keep a denylist, which puts the database back in the path. Cookie sessions revoke immediately on logout.
What does the minimal auth architecture look like?
Two shapes.
Cookie auth (browser-only):
flowchart LR
Browser -->|POST /login| App[ASP.NET Core]
App --> DB[(User store)]
App -->|Set-Cookie<br/>HttpOnly,Secure| Browser
Browser -->|Cookie on every request| App
JWT + OIDC (multi-client, multi-service):
flowchart LR
Client[Web/Mobile/API] -->|login| Auth[OIDC server<br/>Duende/OpenIddict/Keycloak]
Auth --> DB[(User store)]
Auth -->|access + refresh tokens| Client
Client -->|Authorization: Bearer| API1[ASP.NET Core API 1]
Client -->|Authorization: Bearer| API2[ASP.NET Core API 2]
Client -->|refresh| Auth
Pick the simpler one whenever you can. The OIDC shape adds an operational service; only graduate to it when the cookie shape cannot fit the architecture.
What is the .NET 10 wiring for cookie auth?
Five lines for the registration plus a login endpoint:
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(opt =>
{
opt.Cookie.Name = ".AnhTu.Auth";
opt.Cookie.HttpOnly = true;
opt.Cookie.SecurePolicy = CookieSecurePolicy.Always;
opt.Cookie.SameSite = SameSiteMode.Lax;
opt.ExpireTimeSpan = TimeSpan.FromDays(14);
opt.SlidingExpiration = true;
});
builder.Services.AddAuthorization();
builder.Services.AddAntiforgery();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
// Login endpoint:
app.MapPost("/login", async (LoginDto dto, IUserService users, HttpContext ctx) =>
{
var user = await users.VerifyAsync(dto.Email, dto.Password);
if (user is null) return Results.Unauthorized();
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Email, user.Email) };
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
await ctx.SignInAsync(new ClaimsPrincipal(identity));
return Results.Ok();
});
The cookie is HttpOnly so JavaScript cannot read it (XSS safe);
Secure so it only goes over HTTPS; SameSite=Lax so cross-site
forms cannot forge it (CSRF mostly handled). Anti-forgery middleware
plugs the remaining CSRF gaps for state-changing requests.
What is the .NET 10 wiring for JWT?
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(opt =>
{
opt.Authority = "https://auth.anhtu.dev";
opt.Audience = "api.anhtu.dev";
opt.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ClockSkew = TimeSpan.FromSeconds(30),
};
});
// Endpoint that requires auth:
app.MapGet("/me", (ClaimsPrincipal user) =>
new { id = user.FindFirstValue(ClaimTypes.NameIdentifier) })
.RequireAuthorization();
The Authority URL points at the OIDC discovery document; ASP.NET
Core fetches public signing keys, caches them, and rotates them
automatically. Your API never talks to the auth server on the hot
path - JWT validation is local CPU work.
What failure modes does the auth tier introduce?
Five that bite hardest:
- Stolen token via XSS - JavaScript reads
localStorage, sends token to attacker. Mitigation: never store JWT inlocalStorage; use cookies (HttpOnly) or in-memory state. - Token replay - attacker steals a refresh token. Mitigation: refresh-token rotation; detect reuse and revoke all sessions.
- Clock skew - the JWT
expclaim depends on synchronised clocks. A 1-minute skew can fail validation. Mitigation:ClockSkew = 30 sallowance. - Key rotation lag - the auth server rotates signing keys; the API caches old keys. Mitigation: shorter cache TTL on the discovery document, or signal-based invalidation.
- Logout that does not log out - JWT is valid until expiry; a user clicking "log out" expects immediate revocation. Mitigation: short access tokens (5-15 min) plus a denylist for revoked refresh tokens.
Chapter 13 tracks
auth-specific metrics: auth_failures_total,
token_validation_duration_seconds, refresh_reuse_alerts_total.
When should you skip OIDC entirely?
When you have one app, one team, and no plan to share identity. The OIDC stack (Duende or OpenIddict or Keycloak) is one more service to run, one more migration story, one more thing that breaks at 3 AM. Cookie auth on top of ASP.NET Core Identity is a small surface that covers all your needs. The QPS estimate from chapter 2 and the architecture you settled on in chapter 5 tell you whether the simple shape is sufficient. Add OIDC the day you add the second app, not the day you read this chapter.
Where should you go from here?
You have completed the building-blocks group: cache, database, queue, API, search, auth. Next chapter: idempotency and the outbox pattern - the first reliability pattern that ties everything you have learned so far into a system that survives partial failures.