Khối xây dựng Trung bình 5 phút đọc

Auth trong .NET: JWT vs cookie, OIDC, refresh token

Cách chọn giữa JWT và cookie auth trong ASP.NET Core, khi nào outsource sang OIDC (Duende, OpenIddict, Keycloak), và refresh-token rotation chạy ra sao.

Mục lục
  1. Khi nào lựa chọn auth thực sự quan trọng?
  2. Ngân sách số nào cho tier auth?
  3. Kiến trúc auth tối thiểu trông thế nào?
  4. Cấu hình .NET 10 cho cookie auth?
  5. Cấu hình .NET 10 cho JWT?
  6. Tier auth tạo failure mode nào?
  7. Khi nào nên bỏ qua OIDC hoàn toàn?
  8. Đi tiếp đâu từ đây?

Authentication trông đơn giản - "đăng nhập user" - cho đến lúc scale. Sau đó nó đột nhiên là chuyện cờ bảo mật cookie, refresh-token rotation, validate JWT, OIDC discovery document, và intern chán đời phá được cái nào trên prod. Chương này cho cây quyết định cho .NET, kèm các failure mode quyết định mỗi nhánh.

Khi nào lựa chọn auth thực sự quan trọng?

Ba tín hiệu.

Hơn một loại client. App browser một mình lấy cookie miễn phí. Vừa thêm app mobile, app desktop, hay public API thì câu chuyện cookie hỏng - app mobile không dùng cookie cùng cách - và JWT cộng refresh trở nên hấp dẫn.

Hơn một service. Monolith validate session một chỗ. Đội microservice hoặc gọi auth service mỗi request (latency + coupling) hoặc nhận token tự chứa (JWT). Phương án thứ hai scale xa hơn.

Nhiều app chia sẻ identity. App khách + admin nội + portal partner, cùng kho user. Auth tự xây thành gánh bảo trì; OIDC sinh ra cho đúng ca này.

Nếu không cái nào đúng, cookie auth đơn giản nhất là lựa chọn đúng và giữ được nhiều năm.

Ngân sách số nào cho tier auth?

Chiến lược            Chi phí validate    Lưu trữ              Thu hồi
Cookie + DB session   ~1-3 ms (DB)        server-side          ngay
Cookie + Redis sess.  ~0.5 ms (cache)     Redis                ngay
JWT (không DB)        ~50 µs (chữ ký)     stateless            tới TTL
JWT + denylist Redis  ~0.5 ms             Redis                ngay
OIDC (Duende)         ~50 µs validate     phía issuer          token rotation

Lợi "không đụng DB mỗi request" của JWT có thật nhưng nhỏ ở quy mô hiện đại - 1 ms latency DB ở 10K QPS là 10 CPU-giây wall clock. Trade-off lớn hơn là thu hồi: JWT bị đánh cắp vẫn hợp lệ tới hạn trừ khi có denylist, đặt database trở lại đường nóng. Cookie session thu hồi ngay khi logout.

Kiến trúc auth tối thiểu trông thế nào?

Hai hình dạng.

Cookie auth (chỉ browser):

flowchart LR
    Browser -->|POST /login| App[ASP.NET Core]
    App --> DB[(User store)]
    App -->|Set-Cookie<br/>HttpOnly,Secure| Browser
    Browser -->|Cookie mỗi request| App

JWT + OIDC (đa client, đa service):

flowchart LR
    Client[Web/Mobile/API] -->|đăng nhập| Auth[OIDC server<br/>Duende/OpenIddict/Keycloak]
    Auth --> DB[(User store)]
    Auth -->|access + refresh token| Client
    Client -->|Authorization: Bearer| API1[ASP.NET Core API 1]
    Client -->|Authorization: Bearer| API2[ASP.NET Core API 2]
    Client -->|refresh| Auth

Chọn cái đơn giản hơn khi có thể. Hình OIDC thêm một service vận hành; chỉ lên cấp khi hình cookie không vừa với kiến trúc.

Năm dòng cho đăng ký cộng endpoint login:

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();

// Endpoint login:
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();
});

Cookie HttpOnly nên JavaScript không đọc được (XSS an toàn); Secure nên chỉ qua HTTPS; SameSite=Lax nên form xuyên site không giả mạo (CSRF phần lớn được lo). Middleware anti-forgery chèn các kẽ CSRF còn lại cho request thay đổi state.

Cấu hình .NET 10 cho 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 cần auth:
app.MapGet("/me", (ClaimsPrincipal user) =>
    new { id = user.FindFirstValue(ClaimTypes.NameIdentifier) })
   .RequireAuthorization();

URL Authority chỉ vào discovery document OIDC; ASP.NET Core fetch key ký công khai, cache, và rotate tự động. API của bạn không bao giờ nói với auth server trên đường nóng - validate JWT là việc CPU local.

Tier auth tạo failure mode nào?

Năm cái phổ biến nhất:

Chương 13 theo dõi metric riêng auth: auth_failures_total, token_validation_duration_seconds, refresh_reuse_alerts_total.

Khi nào nên bỏ qua OIDC hoàn toàn?

Khi bạn có một app, một team, không định share identity. Stack OIDC (Duende hay OpenIddict hay Keycloak) là một service phải chạy, một câu chuyện migration, một thứ vỡ lúc 3 giờ sáng. Cookie auth trên ASP.NET Core Identity là bề mặt nhỏ phủ mọi nhu cầu của bạn. Ước lượng QPS chương 2 và kiến trúc bạn chọn ở chương 5 cho bạn biết hình đơn giản có đủ không. Thêm OIDC vào ngày bạn thêm app thứ hai, không phải ngày bạn đọc chương này.

Đi tiếp đâu từ đây?

Bạn đã hoàn tất nhóm building blocks: cache, database, queue, API, search, auth. Chương kế tiếp: idempotency và pattern outbox

Câu hỏi thường gặp

Vì sao cookie auth vẫn mặc định cho app browser?
Ba lý do: cookie HttpOnly miễn nhiễm với XSS đánh cắp token (localStorage thì không); browser lo expiry cookie, refresh, và CSRF (với SameSite); cookie auth của ASP.NET Core là cấu hình năm dòng có anti-forgery sẵn. JWT trong localStorage trông hiện đại nhưng tạo rủi ro XSS thật; lợi ích duy nhất phía browser là 'không có session server' - hiếm khi là vấn đề ở quy mô đáng kể.
Khi nào JWT rõ ràng thắng cookie?
Ba trường hợp: (1) client mobile hay desktop không có hạ tầng cookie; (2) traffic microservice nơi mỗi service phải validate token mà không gọi auth service; (3) third-party API qua Authorization: Bearer. Cho mấy cái này, JWT cộng refresh-token rotation là hình dạng đúng. Chi phí validate JWT là một check chữ ký; chi phí validate cookie là lookup database hoặc cache hit.
Tự xây auth hay dùng OIDC?
Tự xây chỉ cho một app, nhu cầu đơn giản, không có team auth. Khi có hai app cần share login, hoặc tích hợp partner, hoặc social sign-in, OIDC có lợi nhanh. Lựa chọn .NET là Duende IdentityServer (thương mại, trưởng thành), OpenIddict (miễn phí, viết nhiều hơn), Keycloak (Java, chạy trong container). Cái giá là vận hành - thêm service - đổi lấy không phải tái phát minh OAuth.
Refresh-token rotation chạy ra sao?
Mỗi lần refresh trả access token mới refresh token mới; refresh token cũ bị vô hiệu. Nếu kẻ tấn công đánh cắp refresh token và dùng, lần refresh kế của client thật fail (server phát hiện reuse), mọi session của user bị thu hồi, user bị buộc đăng nhập lại. Không có rotation, refresh token bị đánh cắp dài hạn cho kẻ tấn công truy cập vĩnh viễn.