Lột trần sự thật về JWT (Phần 1): Cú lừa "Stateless" và nỗi đau Đăng Xuất
Bạn đã bao giờ tự hỏi tại sao 90% các bài hướng dẫn trên mạng đều tung hô JWT như một giải pháp hoàn hảo để thay thế Session, nhưng khi áp dụng vào dự án thực tế, các Senior và Tech Lead lại thường xuyên đau đầu vì nó? JWT không sinh ra để làm khó chúng ta. Vấn đề nằm ở việc hiểu sai bản chất "Stateless" (Phi trạng thái), dẫn đến vô số lỗ hổng bảo mật và những pha xử lý cồng kềnh. Trong phần đầu tiên của loạt bài này, chúng ta sẽ đi sâu vào cấu trúc thực sự của JWT, bóc tách nghịch lý của hệ thống Stateless và giải quyết triệt để bài toán khó nhằn nhất: Đăng xuất (Logout) sao cho an toàn.

Chương 1: Sự thật trần trụi về JWT và "Cú lừa" Stateless
1. Vì sao JWT lại gây ra nhiều "đau khổ" đến vậy?
Nếu bạn tìm kiếm từ khóa "Authentication with JWT" trên mạng, 90% các bài tutorial sẽ vẽ ra một viễn cảnh màu hồng: "Hãy vứt bỏ Session đi! JWT là chuẩn mực mới, nó Stateless, nó giúp hệ thống của bạn scale (mở rộng) vô hạn mà không tốn một xu bộ nhớ nào!"
Nhưng thực tế production lại kể một câu chuyện khác: Khi bạn đưa JWT lên môi trường thực tế, hệ thống bắt đầu phơi bày những lỗ hổng. Sếp yêu cầu bạn: "Hãy block tài khoản của user A ngay lập tức vì họ có hành vi gian lận". Bạn nhận ra user A vẫn cầm cái JWT đó và tung tăng chọc vào API của bạn, còn bạn thì bất lực vì token đó... chưa hết hạn. Lúc này, bạn lén lút dựng thêm một con Redis để lưu danh sách các token bị cấm (Blacklist). Chúc mừng, hệ thống "Stateless" của bạn vừa chính thức trở thành "Stateful"!
Bài viết này không sinh ra để bài xích JWT. Mục tiêu của chúng ta là bóc tách bản chất thật của nó, hiểu rõ nó sinh ra để làm gì, và quan trọng nhất: Khi nào thì tuyệt đối không nên dùng nó.
2. JWT là gì? (Nói đúng bản chất kỹ thuật)
Đừng gọi JWT là một "công cụ xác thực" (Authentication). Bản chất của JSON Web Token (JWT) chỉ là một định dạng chuẩn để truyền tải thông tin (claims) giữa hai bên một cách an toàn.
Sự an toàn này đến từ việc token được ký điện tử (Signed), chứ không phải được mã hóa (Encrypted). Đây là lầm tưởng chí mạng nhất của người mới.
Một JWT luôn có 3 phần được ngăn cách bởi dấu chấm (.):
Header . Payload . Signature

-
Header (Thông tin nhận dạng): Chứa loại token (JWT) và thuật toán ký (ví dụ: HS256, RS256).
-
Payload (Khối lượng hàng hóa): Nơi chứa dữ liệu bạn muốn truyền đi (User ID, Role, Email...). Phần này chỉ được Encode (mã hóa cơ số Base64Url) để truyền đi an toàn qua HTTP, chứ hoàn toàn không được Encrypt (mã hóa bảo mật). Bất kỳ ai cầm được token này đều có thể copy vào jwt.io và đọc được toàn bộ Payload.
-
Signature (Chữ ký niêm phong): Đây là "linh hồn" của JWT. Nó được tạo ra bằng cách lấy Header + Payload băm cùng với một "Khóa bí mật" (Secret Key) nằm trên server. Chữ ký này đảm bảo rằng: Nếu ai đó cố tình sửa data trong Payload (ví dụ: đổi role: user thành role: admin), chữ ký sẽ bị sai lệch và server sẽ từ chối token ngay lập tức.
Ý nghĩa thật sự của "Self-contained" (Tự bao hàm):
Vì Payload đã chứa đủ thông tin (User ID là gì, quyền hạn ra sao) và Signature đã chứng minh thông tin đó là thật, Server không cần phải chọc vào Database để hỏi xem "User mang token này là ai?" nữa. Nó tự kiểm chứng bằng toán học. Đó là sức mạnh lớn nhất của JWT.
3. "Stateless" – Khái niệm bị hiểu sai nhiều nhất
Nhiều lập trình viên nghĩ rằng "Stateless" (Phi trạng thái) nghĩa là hệ thống không cần lưu trữ bất kỳ dữ liệu gì. Đây là một sự hiểu lầm lớn. Stateless ở đây chỉ ám chỉ việc quản lý phiên đăng nhập (Session), chứ không phải toàn bộ hệ thống.
Hãy dùng một ví dụ đời thực để so sánh: Cảnh sát giao thông kiểm tra người đi đường.
-
Mô hình Stateful (Session truyền thống): Bạn đi ra đường không mang giấy tờ, chỉ đọc số CMND. Anh cảnh sát (Server) phải lấy bộ đàm, gọi về Tổng đài (Database/Redis) để hỏi: "Số CMND này là ai? Có bằng lái không? Có bị tước bằng không?". Tổng đài tra cứu (State) và trả lời. Quá trình này rất an toàn, cập nhật theo thời gian thực, nhưng nếu có 1 triệu người cùng đi đường, Tổng đài sẽ quá tải.
-
Mô hình Stateless (Dùng JWT): Bạn mang theo Giấy phép lái xe bằng thẻ PET (chính là JWT). Trên thẻ ghi rõ tên bạn, loại xe được lái (Payload), và quan trọng nhất là có Con dấu chống giả của Sở GTVT (Signature). Anh cảnh sát chỉ cần nhìn con dấu mộc, dùng nghiệp vụ kiểm tra xem dấu có thật không. Nếu dấu thật, anh ấy cho bạn đi tiếp mà không cần gọi về Tổng đài.
Anh cảnh sát trong ví dụ JWT chính là một API hoàn toàn "Stateless". Anh ấy không cần nhớ bạn là ai, cũng không cần hỏi Database. Trạng thái (State) lúc này không nằm trên Server nữa, mà nó nằm ngay trong chính cái thẻ (Token) bạn đang cầm.
4. Vòng đời của một JWT – Điều các tutorial thường bỏ qua
Để làm chủ JWT, chúng ta phải nhìn nó dưới góc độ một vòng đời khép kín: Sinh ra, Xác thực và Chết đi.

-
Được phát hành (Issuance): Khách hàng gửi Username/Password. Server kiểm tra DB thấy đúng, liền tạo ra một JWT, đóng dấu (Sign) bằng Secret Key và gửi về cho khách hàng. Kể từ giây phút này, Server "quên" luôn khách hàng đó.
-
Được xác thực (Validation): Khách hàng kẹp JWT vào HTTP Header (Authorization: Bearer <token>) và gọi API. Server tách lấy Signature, dùng Secret Key để tính toán lại. Nếu khớp, Server đọc Payload để biết User ID là gì và cho phép thực hiện thao tác.
-
Chết đi (Expiration - Nỗi đau bắt đầu từ đây): Một JWT chuẩn mực chỉ chết đi theo một cách duy nhất: Thời gian sống (exp claim trong Payload) đã cạn kiệt. Chữ ký toán học không quan tâm đến việc user có muốn đăng xuất hay không. Chừng nào token chưa hết hạn và chữ ký còn đúng, nó vẫn hợp lệ.
Chương 2: Nỗi đau mang tên "State" và "Logout"
1. Logout với JWT – Bài toán không có lời giải hoàn hảo
Để thấy JWT phiền phức thế nào khi đăng xuất, hãy nhìn lại cách chúng ta làm với Session truyền thống: Khi user bấm Logout, Server chỉ cần vào Database/Redis xóa cái Session ID đó đi. Xong! Kể từ giây phút đó, bất kể ai cầm Session ID cũ gửi lên, Server đều từ chối vì không tìm thấy trong kho lưu trữ. Việc đăng xuất thành công ngay lập tức và tuyệt đối an toàn.
Nhưng với JWT, "Logout" chỉ là một cú lừa trên Frontend. Server hoàn toàn không biết việc bạn vừa xóa token trên trình duyệt. Token đó giống như một tờ vé xem phim đã được in ra và đóng dấu. Rạp phim (Server) không có sổ ghi chép xem ai đã xé vé. Chừng nào suất chiếu chưa kết thúc (chưa đến giờ exp), bất kỳ ai cầm tờ vé đó đến cửa đều được cho vào.
Vì sao nói "Logout" với JWT luôn là best-effort (cố gắng hết sức)? Bởi vì trong một kiến trúc thuần Stateless, bạn không có cách nào thu hồi (revoke) một thứ mà bạn không lưu trữ.
2. Các cách "chữa cháy" phổ biến (và vì sao chúng có vấn đề)
Thực tế production không cho phép chúng ta nói với sếp rằng: "Hacker đang cầm token của user, nhưng mình phải đợi 3 tiếng nữa token mới hết hạn sếp ạ". Do đó, giới kỹ sư đã đẻ ra các cách xử lý sau:
Cách 1: Giảm tuổi thọ của Token (Short-lived Token):
-
Thay vì cho token sống 30 ngày, hãy cho nó sống 15 phút thôi. Giảm thiểu rào cản rủi ro rò rỉ.
-
Vấn đề: Trải nghiệm người dùng (UX) cực kỳ tồi tệ. Chẳng ai muốn cứ 15 phút lại bị văng ra màn hình bắt nhập lại Username/Password.
Cách 2: Đưa vào "Danh sách đen" (Blacklist / Blocklist):
-
Khi user bấm Logout (hoặc khi cần khóa tài khoản khẩn cấp), Server sẽ lấy cái JWT đó (hoặc ID của token là jti) nhét vào một cái bảng trong Redis gọi là Blacklist. Mỗi lần có request gắn JWT gửi tới, sau khi verify chữ ký xong, API Gateway phải chạy vào Redis hỏi xem "Token này có bị cấm không?".
-
Vấn đề chí mạng: Khoan từ từ
Chẳng phải chúng ta dùng JWT để không phải chọc vào Database/Redis ở mỗi request sao? Việc sinh ra một cái Blacklist đã chính thức phá nát nguyên lý Stateless của JWT. Chúc mừng, bạn đã quay trở lại với mô hình Stateful Session, nhưng với một cơ chế cồng kềnh hơn rất nhiều!
3. Access Token & Refresh Token – Cứu tinh UX hay "Cú lừa" bảo mật tiếp theo?
Để giải quyết bài toán "Token sống ngắn thì trải nghiệm người dùng (UX) tệ" ở phần trước, giới công nghệ áp dụng một Pattern kinh điển: Chia quyền lực ra làm hai loại Token.

-
Access Token (Vé vào cổng): Tuổi thọ cực ngắn (10 - 15 phút). Nhiệm vụ duy nhất của nó là đính kèm vào mỗi HTTP Request để gọi API. Vì nó sống ngắn, nên nếu hacker có chộp được (Leak), thiệt hại cũng được giảm thiểu tối đa vì chỉ 15 phút sau là vé này thành giấy lộn.
-
Refresh Token (Thẻ thành viên): Tuổi thọ rất dài (7 ngày, 30 ngày, thậm chí 1 năm). Nó tuyệt đối không được dùng để gọi API nghiệp vụ. Nhiệm vụ duy nhất của nó là: Khi Access Token hết hạn, Frontend sẽ mang Refresh Token này gửi lên một API đặc biệt (/auth/refresh) để xin cấp một Access Token mới, mà không bắt người dùng phải gõ lại mật khẩu.
Góc nhìn Kiến trúc (The Senior/SA Perspective): Nghịch lý của sự Stateless
Nhìn bề ngoài, cơ chế này thật hoàn hảo. UX được đảm bảo, rủi ro Access Token bị lộ được giảm xuống mức thấp nhất. Nhưng hãy phân tích sự đánh đổi (Trade-off) ở đây:
Refresh Token có quyền lực rất lớn—nó là "cỗ máy in" ra các Access Token mới. Nếu hacker trộm được Refresh Token, hắn có thể liên tục in ra vé mới để thao túng tài khoản của bạn trong suốt 30 ngày.
Để ngăn chặn thảm họa này, khi người dùng bấm "Logout" hoặc khi phát hiện có biến, hệ thống bắt buộc phải có khả năng thu hồi (Revoke) Refresh Token. Và làm sao để thu hồi một Token? Đúng vậy, hệ thống không còn cách nào khác là phải lưu trữ danh sách các Refresh Token (hoặc trạng thái của chúng) vào Database hoặc Redis!
Sự thật phũ phàng dành cho các hệ thống dùng JWT: Chúng ta chạy trốn khỏi Session truyền thống vì muốn hệ thống hoàn toàn Stateless. Nhưng để bảo mật cho hệ thống Stateless đó, chúng ta lại phải đem Refresh Token cất vào Database, biến hệ thống trở lại thành Stateful.
Giải pháp được giới bảo mật khuyên dùng (Best Practice) hiện nay là:
-
Cất Refresh Token vào một Cookie. Nhưng không phải Cookie thông thường. Nó phải được gài thêm các cờ bảo mật:
-
HttpOnly: Cấm JavaScript đọc Cookie (chặn đứng XSS).
-
Secure: Chỉ cho phép truyền Cookie qua mạng mã hóa HTTPS.
-
SameSite=Strict: Chỉ đính kèm Cookie này nếu request được gọi từ chính xác tên miền của hệ thống. Bất kỳ request nào bị kích hoạt từ một trang web giả mạo (CSRF) đều bị chặn lại.
-
-
Giữ Access Token ngay trên bộ nhớ RAM (như một biến State). Nếu user F5 tải lại trang, Access Token mất, nhưng Frontend sẽ tự động gọi API /refresh ngầm. Lúc này, trình duyệt sẽ tự động đính kèm cái HttpOnly Cookie kia đi theo để xin lại Access Token mới.
Tổng kết Phần 1
Đến đây, chúng ta đã giải quyết xong bài toán bảo mật JWT trên bề mặt Frontend. Bằng việc kết hợp Access Token ngắn hạn và cất giấu Refresh Token vào HttpOnly Cookie kèm cờ SameSite=Strict, bạn đã xây dựng được một hàng rào phòng thủ khá vững chắc trước các đòn tấn công XSS và CSRF.
Nhưng đó mới chỉ là khởi đầu. Khi hệ thống của bạn phát triển từ một Server nguyên khối (Monolith) lên quy mô hàng chục Microservices, một bài toán hóc búa khác lại xuất hiện: Hệ thống sẽ phải xác thực hàng ngàn chữ ký JWT mỗi giây như thế nào để không bị nghẽn cổ chai? Làm sao để thu hồi quyền truy cập ngay lập tức trên toàn bộ các service nhỏ lẻ? Chúng ta sẽ cùng giải phẫu những vấn đề ở tầm vóc kiến trúc này trong phần tiếp theo Lột trần sự thật về JWT (Phần 2): Trận chiến Microservices và nỗi ám ảnh "Token Béo Phì"". Hẹn gặp lại các bạn!
All rights reserved