Bài JWT trước dừng ở việc kiểm token cho đúng. Bài này đo một câu hỏi khó hơn, và là điểm yếu cốt lõi của JWT: làm sao thu hồi một token trước khi nó hết hạn? Người dùng đổi mật khẩu, một thiết bị bị mất, một tài khoản bị khoá — bạn muốn token cũ chết ngay. Với phiên lưu trên máy chủ (bài phiên đăng nhập), chỉ cần xoá phiên. Với JWT không trạng thái, mọi thứ khó hơn nhiều, và bài này đo chính xác khó tới đâu.
Toàn bộ chạy trên ứng dụng tự dựng trong container, tự dọn. Không nhắm vào hệ thống của ai.
Vì sao JWT khó thu hồi
Sức mạnh của JWT là không trạng thái: máy chủ không lưu gì, nó chỉ kiểm chữ ký. Một token hợp lệ về chữ ký và còn hạn thì được chấp nhận, không cần hỏi cơ sở dữ liệu. Đó là lý do JWT mở rộng tốt — nhưng cũng là lý do nó không thu hồi được: nếu máy chủ không tra cứu gì, nó không có nơi nào để ghi "token này đã bị cấm".
Số đo
Tôi phát một token, "thu hồi" nó (thêm định danh của nó vào một danh sách đen), rồi kiểm nó qua hai route: một route thuần JWT (chỉ kiểm chữ ký) và một route có tra danh sách đen. Ba lần chạy, giống hệt:
| Sau khi thu hồi | Route thuần JWT | Route tra danh sách đen |
|---|---|---|
| Token còn dùng được? | CÓ — vẫn chấp nhận | không — chặn ngay |
Route thuần JWT không hề biết token đã bị thu hồi, vì nó không tra cứu gì. Việc "thu hồi" chỉ có nghĩa khi có ai đó chịu tra danh sách.
Cửa sổ thu hồi bằng đúng thời hạn còn lại
Nếu bạn không thêm danh sách đen (giữ JWT thuần), token bị thu hồi vẫn sống — nhưng bao lâu? Tôi thu hồi ngay sau khi phát, rồi đếm token còn được chấp nhận thêm bao lâu, với ba mức hạn:
| Hạn token | Sống thêm sau khi thu hồi |
|---|---|
| 2 giây | ~1,3 giây |
| 5 giây | ~4,4 giây |
| 10 giây | ~9,5 giây |
Con số gần đúng bằng hạn: token thu hồi sống cho tới khi hết hạn tự nhiên, vì đó là điều duy nhất route thuần JWT kiểm. Đây là kết luận trung tâm: với JWT thuần, "cửa sổ thu hồi" chính là thời hạn của token. Đặt hạn 15 phút nghĩa là một token bị lộ vẫn dùng được tối đa 15 phút sau khi bạn cố thu hồi.
Một chi tiết trong cách đo đáng nói: tôi không giả định con số, tôi đếm nó. Sau khi thu hồi, script gọi lại route thuần JWT mỗi nửa giây và ghi mốc cuối cùng token còn được chấp nhận. Con số ~1,3 / ~4,4 / ~9,5 giây luôn nhỏ hơn hạn một chút — đúng bằng thời gian đã trôi kể từ lúc phát tới lúc thu hồi. Điều này xác nhận cơ chế: không có gì "hết hiệu lực" khi thu hồi cả; token đơn giản chạy hết đồng hồ exp của nó như thể tôi chưa từng thu hồi.
Đánh đổi: hạn ngắn hay hạn dài
Điều này biến việc chọn thời hạn thành một đánh đổi trực tiếp, không có lựa chọn hoàn hảo:
- Hạn ngắn (vài phút): cửa sổ thu hồi hẹp — token lộ chỉ dùng được vài phút. Nhưng client phải làm mới liên tục, mỗi lần là một vòng gọi mạng, và nếu làm mới cũng dùng JWT thuần thì bạn chỉ dời vấn đề.
- Hạn dài (nhiều ngày): ít làm mới, nhẹ cho hệ thống. Nhưng cửa sổ thu hồi rộng bằng nhiều ngày — một token bị đánh cắp là nhiều ngày kẻ tấn công tự do.
Không có con số "đúng"; có một đường cong, và bạn chọn điểm trên đó theo mức độ nhạy cảm của ứng dụng.
Cách thoát: refresh token có trạng thái
Mẫu phổ biến nhất tách token làm hai loại, mỗi loại nhận một nửa của đánh đổi:
- Access token — JWT thuần, hạn rất ngắn (phút). Không trạng thái, kiểm nhanh, mở rộng tốt. Cửa sổ thu hồi hẹp vì hạn ngắn.
- Refresh token — có trạng thái, lưu trên máy chủ, hạn dài. Chỉ dùng để đổi lấy access token mới.
Tôi đo phần thu hồi của refresh token: phát một refresh, làm mới thành công một lần, rồi thu hồi refresh, làm mới lần nữa:
| Kết quả | |
|---|---|
| Làm mới lần 1 | thành công |
| Thu hồi refresh token | — |
| Làm mới lần 2 | chặn ngay lập tức |
Vì refresh token có trạng thái (máy chủ lưu và tra), thu hồi nó có hiệu lực tức thì — khác hẳn access token JWT thuần. Kết hợp lại: access token hết hạn sau vài phút, và nếu refresh đã bị thu hồi, không có access token mới nào được cấp. Cửa sổ tấn công tối đa co lại còn bằng đúng hạn ngắn của access token.
Vì sao mẫu này thắng
Nó chia đánh đổi thay vì chọn một đầu:
- Đường nóng (mọi request API) dùng access token không trạng thái — nhanh, mở rộng tốt, đúng thế mạnh của JWT.
- Đường nguội (làm mới, vài phút một lần) dùng refresh token có trạng thái — chậm hơn một chút nhưng thu hồi được, đúng thứ JWT thiếu.
Cái giá phải trả rất thật: bạn lại có trạng thái trên máy chủ cho refresh token, nghĩa là mất một phần ưu điểm "không trạng thái" ban đầu. Nhưng bạn chỉ trả nó ở đường nguội, nơi tần suất thấp. Đây là điều đáng nhớ nhất: JWT thuần không phải giải pháp thu hồi, nó là giải pháp mở rộng; muốn cả hai thì phải thêm lại đúng lượng trạng thái tối thiểu, ở đúng chỗ.
Nghĩa là gì trong thực tế
- Đặt access token JWT hạn ngắn — thường 5 tới 15 phút. Đây là trần của cửa sổ thu hồi; đừng để nó dài hàng giờ.
- Dùng refresh token có trạng thái cho việc làm mới, lưu trên máy chủ để thu hồi được ngay. Đăng xuất, đổi mật khẩu, khoá tài khoản đều phải thu hồi refresh token.
- Đừng cố "đăng xuất" một JWT thuần bằng cách xoá nó phía client. Bản sao kẻ tấn công giữ không bị xoá; chỉ hết hạn
expmới thật sự chấm dứt nó. - Nếu bắt buộc thu hồi access token tức thì, bạn cần danh sách đen — và chấp nhận rằng mọi request giờ phải tra nó, tức là JWT của bạn không còn thật sự không trạng thái ở đường nóng.
Chỗ tôi không kết luận được
Danh sách đen access token (route thứ hai của tôi) cũng thu hồi được ngay — nhưng nó cũng thêm trạng thái và một lần tra cứu vào mọi request, tức là bỏ luôn ưu điểm không trạng thái ở đường nóng. Tôi đo rằng nó hoạt động, không đo chi phí của việc tra danh sách đen ở mọi request dưới tải cao; đó là một phép đo hiệu năng riêng.
Tôi cũng không đo refresh token rotation — mỗi lần làm mới cấp một refresh mới và huỷ cái cũ, để phát hiện khi một refresh bị dùng lại (dấu hiệu bị đánh cắp). Đó là lớp phòng thủ thêm trên mẫu này, và xứng một bài riêng.
Thử ba mươi giây
Tìm nơi ứng dụng của bạn cấp JWT. Thời hạn (exp) là bao lâu? Và khi người dùng đổi mật khẩu hay đăng xuất, có gì khiến token đang có của họ ngừng hoạt động ngay không?
Nếu thời hạn dài (giờ, ngày) và không có danh sách đen hay refresh token có trạng thái, thì "đăng xuất" của bạn chỉ xoá token phía client — bản sao mà kẻ tấn công giữ vẫn dùng được cho tới tận khi exp trôi qua.