Backend

99 Ngày Spring — Ngày 46: Refresh token & logout

SSite Admin
11 tháng 09, 2026 8 phút đọc 11 lượt xem
99 Ngày Spring — Ngày 46: Refresh token & logout

Hôm qua ta dựng xong filter đọc JWT, và kết bài bằng một câu chưa trả lời trọn: JWT không thu hồi được. Người dùng bấm đăng xuất, token vẫn sống tới lúc hết hạn. Hôm nay là lời giải của ngành cho chuyện đó — cặp access token ngắn hạn cộng refresh token lưu trong database — cùng hai thứ đi kèm: rotation để phát hiện token bị trộm, và một cái nhìn thẳng vào chuyện đăng xuất thật sự nghĩa là gì.

Sketchnote Ngày 46: refresh token và logout — access 15 phút cộng refresh 7 ngày lưu DB, rotation phát hiện dùng lại token thì thu hồi cả họ, cookie httpOnly SameSite Strict, và sự thật về đăng xuất của JWT

Hai tờ giấy, vì một con số không đủ

Gốc rễ của mọi thứ hôm nay là một mâu thuẫn không hòa giải được: JWT nhanh vì không tra database, mà đúng vì không tra database nên không thu hồi được. Đặt hạn bảy ngày thì token bị lộ là kẻ trộm dùng cả tuần; đặt năm phút thì người dùng đăng nhập lại suốt ngày. Không con số nào vừa lòng cả hai, nên ta tách làm hai tờ giấy có nhiệm vụ khác nhau.

// VÌ SAO CẦN REFRESH TOKEN — bắt nguồn từ một mâu thuẫn không thể hòa giải.

// JWT là tờ giấy TỰ CHỨNG THỰC: server chỉ kiểm chữ ký, không tra database.
// Nhanh, không cần trạng thái — nhưng cũng KHÔNG THU HỒI ĐƯỢC.
// Người dùng bấm "đăng xuất" xong, tờ giấy đó vẫn hợp lệ tới lúc hết hạn.

// Vậy đặt hạn bao lâu?
//   7 ngày  → tiện, nhưng token bị lộ là kẻ trộm dùng được cả tuần 😱
//   5 phút  → an toàn, nhưng người dùng đăng nhập lại suốt ngày 😤
// Không có con số nào vừa lòng cả hai. Nên ta dùng HAI tờ giấy khác nhau:

//   ACCESS TOKEN   JWT, sống 15 phút, gửi kèm MỌI request, không tra DB
//   REFRESH TOKEN  chuỗi ngẫu nhiên, sống 7 ngày, CHỈ gửi tới /auth/refresh,
//                  LƯU TRONG DATABASE

// ❗ Refresh token KHÔNG nên là JWT. Nghe ngược đời, nhưng đúng:
//    mục đích tồn tại của nó LÀ để thu hồi được — mà JWT thì không thu hồi được.
//    Nó chỉ cần là chuỗi ngẫu nhiên đủ dài, tra bảng ra chủ nhân.
String token = new BigInteger(256, new SecureRandom()).toString(36);
// ❗ Dùng SecureRandom, KHÔNG dùng Random hay UUID.randomUUID() cho việc này.

@Entity
class RefreshToken {
    @Id String token;          // chuỗi ngẫu nhiên ở trên
    String username;
    Instant hetHan;
    boolean daThuHoi;          // ⭐ CHÍNH LÀ khả năng JWT không có
    String giaDinh;            // id "họ" token — dùng cho rotation, xem phần sau
}
  • Access token là JWT sống 15 phút, gửi kèm mọi request, không tra DB — giữ nguyên cái nhanh của JWT.

  • Refresh token là chuỗi ngẫu nhiên sống 7 ngày, chỉ gửi tới /auth/refresh, và nằm trong database — nên thu hồi được.

  • Nghe ngược đời nhưng đúng: refresh token không nên là JWT, vì lý do tồn tại của nó chính là để thu hồi được.

  • Sinh token bằng SecureRandom, đừng dùng Random hay UUID.randomUUID() — chúng không được thiết kế để chống đoán.

Rotation — cách phát hiện token bị trộm

Lưu vào database mới chỉ cho bạn khả năng thu hồi. Rotation cho bạn thêm một thứ nữa: biết khi nào cần thu hồi. Ý tưởng gọn: mỗi lần làm mới thì refresh token cũ chết luôn và bạn nhận một cái mới. Nhờ vậy, một token đã dùng rồi mà quay lại là bằng chứng gần như chắc chắn có bản sao đang chạy đâu đó.

// ROTATION — mỗi lần làm mới thì ĐỔI LUÔN refresh token.

@Transactional
public CapToken lamMoi(String tokenCu) {
    RefreshToken rt = repo.findById(tokenCu)
        .orElseThrow(() -> new BadCredentialsException("Token không hợp lệ"));

    // ⭐ TRÁI TIM CỦA ROTATION: token đã dùng rồi mà quay lại =
    //    hoặc kẻ trộm đang dùng bản sao, hoặc người thật dùng bản đã bị trộm.
    //    Không phân biệt được ai — nên THU HỒI CẢ HỌ, bắt đăng nhập lại.
    if (rt.isDaThuHoi()) {
        repo.thuHoiCaHo(rt.getGiaDinh());          // ⚠️ báo động, cắt sạch
        throw new BadCredentialsException("Phát hiện dùng lại token");
    }
    if (rt.getHetHan().isBefore(Instant.now()))
        throw new BadCredentialsException("Token hết hạn");

    rt.setDaThuHoi(true);                          // token cũ chết ngay tại đây
    RefreshToken moi = repo.save(sinhToken(rt.getUsername(), rt.getGiaDinh()));
    return new CapToken(jwt.taoAccessToken(rt.getUsername()), moi.getToken());
}

// KHÔNG rotation: token bị trộm dùng song song với người thật, KHÔNG AI BIẾT.
// CÓ rotation: hai bên cùng dùng thì chắc chắn có một lần "dùng lại" →
//              hệ thống phát hiện được, dù không biết ai là kẻ trộm.

// ❗ @Transactional bắt buộc: kiểm tra, thu hồi và tạo mới phải là MỘT giao dịch.
//    Hai request gọi /refresh cùng lúc mà không có transaction thì cả hai cùng qua.
  • Không rotation thì kẻ trộm dùng song song với người thật mà không ai biết; có rotation thì thế nào cũng xảy ra một lần "dùng lại", và đó là chuông báo động.

  • Phát hiện dùng lại thì thu hồi cả họ token chứ không riêng cái đó — vì bạn không biết bên nào là kẻ trộm.

  • @Transactional là bắt buộc: kiểm tra, thu hồi và cấp mới phải nằm trong một giao dịch, nếu không hai request /refresh song song sẽ cùng qua.

Lưu ở đâu, và đăng xuất thật sự là gì

Câu hỏi "localStorage hay cookie" gây tranh cãi nhiều nhất, nhưng nó không có đáp án tuyệt đối — nó là chọn mối đe dọa nào để phòng. localStorage thì JavaScript đọc được nên dính XSS; cookie httpOnly thì miễn nhiễm XSS nhưng trình duyệt tự gửi kèm nên phải lo CSRF của Ngày 45. Lựa chọn mặc định nên dùng là cookie httpOnly cộng SameSite=Strict.

// LƯU Ở ĐÂU — câu hỏi gây tranh cãi nhất, và câu trả lời là "tùy mối đe dọa".

// ① localStorage  — JavaScript đọc được → dính XSS.
//    Một script lạ chèn vào trang là đọc sạch token gửi đi nơi khác.
// ② Cookie httpOnly — JavaScript KHÔNG đọc được → miễn nhiễm XSS...
//    ...nhưng trình duyệt tự gửi kèm mọi request → quay lại nỗi lo CSRF (Ngày 45).

ResponseCookie c = ResponseCookie.from("refresh_token", token)
    .httpOnly(true)                  // JS không đọc được
    .secure(true)                    // chỉ đi qua HTTPS
    .sameSite("Strict")              // ⭐ chính là thứ chặn CSRF ở đây
    .path("/api/auth/refresh")       // ❗ chỉ gửi tới ĐÚNG endpoint này
    .maxAge(Duration.ofDays(7))
    .build();

// ⭐ Cặp httpOnly + SameSite=Strict là lựa chọn mặc định nên dùng:
//    chặn được XSS đọc trộm, và SameSite lo phần CSRF.
//    Access token thì giữ trong BỘ NHỚ của ứng dụng (biến JS), không cần lưu đâu cả —
//    nó chỉ sống 15 phút, mất thì gọi /refresh lấy cái mới.

// ĐĂNG XUẤT THẬT SỰ — ba việc, thiếu việc nào cũng chưa gọi là đăng xuất:
@PostMapping("/logout")
public ResponseEntity<Void> logout(@CookieValue String refresh_token) {
    repo.thuHoi(refresh_token);                       // ① chết ở phía SERVER
    ResponseCookie xoa = ResponseCookie.from("refresh_token", "")
        .httpOnly(true).path("/api/auth/refresh").maxAge(0).build();   // ② xóa cookie
    return ResponseEntity.noContent()
        .header(SET_COOKIE, xoa.toString()).build();
}
// ③ Client xóa access token khỏi bộ nhớ.

// ❗ SỰ THẬT PHẢI NÓI RÕ: access token đã phát ra thì VẪN SỐNG tới khi hết hạn.
//    "Đăng xuất" của JWT nghĩa là "không cấp thêm", không phải "vô hiệu ngay".
//    Muốn vô hiệu tức thì thì phải có sổ đen (denylist) trong Redis — và lúc đó
//    bạn đã đánh đổi mất tính không-trạng-thái, thứ khiến JWT hấp dẫn ngay từ đầu.
//    15 phút là con số mua được sự yên tâm mà không phải trả giá đó.
  • Access token thì giữ trong bộ nhớ ứng dụng, không lưu đâu cả — mất thì gọi /refresh, vì nó chỉ sống 15 phút.

  • path của cookie đặt đúng /api/auth/refresh để nó không bị gửi kèm mọi request vô ích.

  • Đăng xuất là ba việc: thu hồi ở server, xóa cookie, và client bỏ access token khỏi bộ nhớ.

  • Nói thẳng với nhau: access token đã phát thì vẫn sống tới lúc hết hạn. Muốn vô hiệu tức thì phải có sổ đen trong Redis — và bạn đánh mất tính không-trạng-thái, thứ khiến JWT hấp dẫn ngay từ đầu.

Bài tập nhỏ

  • Đăng nhập, gọi /refresh, rồi gọi lại bằng token — xem cả họ token bị cắt sạch.

  • Bỏ @Transactional rồi bắn hai request /refresh song song để thấy cả hai cùng qua.

  • Đăng xuất rồi thử access token cũ ngay lập tức — nó vẫn chạy. Đợi quá 15 phút rồi thử lại.

  • Đổi SameSite từ Strict sang None và tự dựng một trang khác gọi sang để thấy CSRF quay lại.

  • Thử đọc cookie httpOnly bằng document.cookie trong console — thấy nó không có ở đó.

Kết lại

Bốn ý gói lại hôm nay: dùng hai tờ giấy vì một con số hạn không thể vừa lòng cả tiện lẫn an toàn — access 15 phút cộng refresh 7 ngày lưu trong database; refresh token không nên là JWT, vì lý do nó tồn tại chính là để thu hồi được; rotation biến việc "dùng lại token" thành chuông báo động, và khi chuông kêu thì thu hồi cả họ; và đăng xuất của JWT là "không cấp thêm", không phải "vô hiệu ngay" — nói rõ điều đó với đội của bạn còn hơn để ai đó phát hiện lúc đang có sự cố. Ngày 47 ta sang chuyện phân quyền: hasRole khác hasAuthority chỗ nào, và @PreAuthorize đặt quyền ngay trên method. Hẹn gặp lại!

S

Site Admin

Engineer and writer. Building things with TypeScript and distributed systems.

Bình luận (0)

Bạn cần đăng nhập bằng Google để bình luận.

Hãy là người bình luận đầu tiên.

Bài viết liên quan

99 Ngày Spring — Ngày 47: Phân quyền — role & authority

Role chỉ là authority có tiền tố ROLE_, vì sao hasRole("ROLE_ADMIN") luôn cho 403, thiết kế role gom quyền để thêm vai trò không phải sửa code — và hai thứ im lặng làm hỏng method security.

12 thg 9, 20267 phút0
99 Ngày Java — Ngày 47: Serialization & JSON

Vì sao Serializable của Java bị cả ngành rời bỏ, ObjectMapper tạo một lần dùng mãi, bẫy TypeReference khi đọc List do xóa kiểu — và vì sao record hợp với JSON tới mức không cần annotation nào.

12 thg 9, 20267 phút0
99 Ngày Java — Ngày 46: NIO.2 — Path & Files

Path là một cái tên chứ không phải một file, resolve và normalize cùng bẫy path traversal, Files ném ngoại lệ có tên thay vì trả false, ATOMIC_MOVE để ghi file an toàn — và walk, find, glob thay hẳn đệ quy tự viết.

11 thg 9, 20267 phút6