Điểm mấu chốt: JWT stateless không thể thu hồi — server chỉ verify chữ ký, không tra cứu gì. Nên thiết kế phải tách hai loại token với vòng đời khác nhau.
Cấu trúc:
- Access token: JWT, sống ngắn (5-15 phút), chứa sub và authorities. Không lưu server. Hết hạn là hết quyền — đây chính là cơ chế thu hồi.
- Refresh token: chuỗi ngẫu nhiên (không cần là JWT), sống dài (7-30 ngày), lưu hash trong DB kèm user_id, expires_at, revoked_at, device. Vì có bản ghi nên thu hồi được ngay.
Luồng refresh:
1. Access token hết hạn → API trả 401 → client gọi POST /auth/refresh kèm refresh token.
2. Server tra hash trong DB, kiểm tra chưa hết hạn và chưa bị thu hồi.
3. Cấp access token mới và refresh token mới, đánh dấu token cũ đã dùng — gọi là rotation.
Rotation + phát hiện replay: nếu một refresh token đã dùng rồi lại được gửi lên, khả năng cao token đã bị đánh cắp → thu hồi toàn bộ chuỗi token của phiên đó và buộc đăng nhập lại. Đây là khuyến nghị trong OAuth 2.0 Security BCP.
Logout / thu hồi quyền:
- Logout = xoá (hoặc revoked_at) bản ghi refresh token. Access token còn lại tự chết trong vài phút — chấp nhận cửa sổ ngắn đó là cái giá của stateless.
- Cần chặn tức thì (khoá tài khoản, đổi role) thì thêm denylist theo jti trong Redis với TTL bằng thời gian sống còn lại của access token. Danh sách rất nhỏ vì access token ngắn hạn.
Lưu ở client: refresh token nên nằm trong cookie HttpOnly; Secure; SameSite=Strict giới hạn path /auth/refresh, không để localStorage (đọc được bằng XSS).