Hãy so hai loại vé vào cửa. Loại thứ nhất: một tấm vé giấy có dập chìm chống giả — người soát vé chỉ cần nhìn con dấu là cho vào, không tra sổ, nên hàng người đi rất nhanh. Nhưng nếu bạn làm mất vé và muốn huỷ nó, bạn bó tay: không có sổ nào ghi "vé số này đã huỷ", và người soát vé vẫn thấy con dấu thật nên vẫn cho kẻ nhặt được vào. Loại thứ hai: một vòng tay điện tử mà cửa quét vào máy chủ mỗi lần — chậm hơn, nhưng huỷ một cái là nó chết ngay. JWT không trạng thái chính là tấm vé giấy, và bài này tôi đo xem "huỷ" nó vô nghĩa đến mức nào.
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ủ, chỉ cần xoá phiên. Với JWT không trạng thái, mọi thứ khó hơn nhiều, và tôi đo chính xác khó tới đâu.
Toàn bộ chạy trên ứng dụng tôi tự dựng trong container, tự dọn. Tôi chỉ tấn công tiến trình của chính mình, 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". Đúng tấm vé giấy không có sổ huỷ.
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 công việc hằng ngày
- Đặ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.
Cùng một đánh đổi "không trạng thái đổi lấy khả năng thu hồi", đổi ngăn xếp
Chuyện "không trạng thái thì mở rộng tốt nhưng không thu hồi được, có trạng thái thì thu hồi tức thì nhưng phải lưu" không riêng gì JWT — nó là căng thẳng trung tâm của mọi hệ xác thực phân tán.
Trong thế giới Java, Spring Security hỗ trợ cả hai và phơi bày đúng đánh đổi này: một JwtDecoder kiểm chữ ký thuần túy không trạng thái (nhanh, không thu hồi), còn OAuth2 token introspection (/introspect) hay một OAuth2TokenRevocationEndpoint thì có gọi về authorization server — thu hồi được nhưng thêm một vòng mạng mỗi request. Chuẩn thực tế trong hệ Spring lớn đúng như bài này: access token JWT ngắn hạn + refresh token lưu trong DB, đăng xuất là xoá refresh.
Trong Go, cùng mẫu: golang-jwt kiểm token thuần không trạng thái, còn để thu hồi người ta thêm một tầng — thường là Redis lưu một tập jti (JWT ID) bị thu hồi, hoặc lưu refresh token. Cái Redis đó chính là "đúng lượng trạng thái tối thiểu ở đúng chỗ" mà bài này nói.
Còn ở cơ sở dữ liệu — đây là nơi refresh token thật sự sống. CLAUDE.md của chính blog này ghi lại đúng tinh thần đó ở một ngữ cảnh khác: token đặt lại mật khẩu là mã dùng một lần, có hạn, chống spam, lưu server và kiểm mỗi lần — tức có trạng thái để thu hồi/hết hạn được, không phải một token tự chứng. Mọi hệ auth nghiêm túc đều có một bảng sessions/refresh_tokens trong DB, và "đăng xuất mọi thiết bị" chính là một câu DELETE trên bảng đó.
Mẫu số chung: không trạng thái (self-contained) và thu-hồi-được là hai thứ đối nghịch — token tự chứng minh bằng chữ ký thì máy chủ không cần tra, nhưng chính vì không tra nên không thể cấm; muốn cấm được thì phải có một nơi để tra, tức là trạng thái. Kiến trúc thắng luôn là chia đôi: phần nóng, tần suất cao đi theo self-contained cho nhanh, phần nguội, ít gặp (làm mới, đăng xuất) gánh trạng thái để thu hồi. Nên câu hỏi đúng khi thiết kế token không phải "JWT hay session" mà là "khi tôi cần một token chết ngay, có nơi nào ghi được điều đó và có ai chịu tra nó không" — nếu không, cửa sổ thu hồi của bạn dài đúng bằng exp.
Muốn tự soát, 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.