Building Blocks Intermediate 5 min read

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
  1. When does the auth choice actually matter?
  2. What numbers should I budget for the auth tier?
  3. What does the minimal auth architecture look like?
  4. What is the .NET 10 wiring for cookie auth?
  5. What is the .NET 10 wiring for JWT?
  6. What failure modes does the auth tier introduce?
  7. When should you skip OIDC entirely?
  8. 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.

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:

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.

Frequently asked questions

Why is cookie auth still the default for browser apps?
Three reasons: HttpOnly cookies are immune to XSS token theft (localStorage is not); the browser handles cookie expiry, refresh, and CSRF protection (with SameSite); ASP.NET Core's cookie auth is a five-line config with anti-forgery built in. JWT in localStorage looks modern but creates a real XSS risk; the only browser-side benefit is 'no server session' - which is rarely a problem at the scale that matters.
When does JWT clearly win over cookies?
Three cases: (1) mobile or desktop clients that do not have cookie infrastructure; (2) microservice traffic where each service must validate the token without calling an auth service; (3) third-party API access via Authorization: Bearer. For these, JWT plus refresh-token rotation is the right shape. The validation cost of JWT is one signature check; the validation cost of a cookie is a database lookup or a cache hit.
Should I roll my own auth or use OIDC?
Roll your own only for a single app with simple needs and no auth team. Once you have two apps that should share login, or a partner integration, or social sign-in, OIDC pays back fast. The .NET options are Duende IdentityServer (commercial, mature), OpenIddict (free, more code), Keycloak (Java, runs in a container). The cost is operational - one more service to run - in exchange for not reinventing OAuth.
How does refresh-token rotation work?
Each refresh exchange returns a new access token and a new refresh token; the old refresh token is invalidated. If an attacker steals a refresh token and uses it, the legitimate client's next refresh fails (server detects reuse), all sessions for that user are revoked, and the user is forced to log in again. Without rotation, a stolen long-lived refresh token gives an attacker permanent access.