Tại sao JWT là một cái bẫy và ứng dụng của bạn thực sự không cần nó

Phần mềm23 tháng 5, 2026·12 phút đọc

JWT thường được sử dụng như một mặc định sai lầm, gây ra sự phức tạp không cần thiết và rủi ro bảo mật về việc thu hồi token. Bài viết lập luận rằng Session hoặc Opaque token đơn giản hơn, an toàn hơn và hiệu quả hơn cho hầu hết các ứng dụng web và API.

Tại sao JWT là một cái bẫy và ứng dụng của bạn thực sự không cần nó

Tại sao JWT là một cái bẫy và ứng dụng của bạn thực sự không cần it

Tôi đã chán việc phải giả vờ rằng JWT (JSON Web Token) là ổn. Thực tế là nó không ổn. Nó giống như một trào lưu sùng bái rập khuôn (cargo cult). JWT giải quyết một vấn đề mà ứng dụng của bạn gần như chắc chắn không gặp phải, nhưng lại tạo ra bốn hoặc năm vấn đề mà ứng dụng của bạn chắc chắn đang phải đối mặt.

JWT SchematicJWT Schematic

Một thế hệ các lập trình viên backend đã bị ép buộc phải sử dụng nó chỉ vì một bài đăng blog nào đó vào năm 2014 đã dùng từ "stateless" (vô trạng thái) như thể đó là một đức hạnh thay vì một sự đánh đổi. Mọi ứng dụng Laravel tôi bắt đầu với jwt-auth cuối cùng tôi lại gỡ bỏ nó. Mọi hệ thống dựa trên JWT tôi đã kiểm toán đều có cùng một câu chuyện thu hồi token bị hỏng, cùng một điệu nhảy làm mới (refresh) vô dụng, và cùng một mã cơ sở client giải mã payload và tin tưởng nó mù quáng.

Nếu bạn đang xây dựng một ứng dụng web, ứng dụng di động hoặc API bên party thứ nhất: JWT là lựa chọn mặc định sai lầm và bạn nên ngừng sử dụng nó. Một hàng trong Postgres với một bearer token ở phía trước nhanh hơn, đơn giản hơn và nghiêm ngặt hơn an toàn hơn.

JWT thực chất là gì và nó được quảng cáo ra sao?

Một JWT bao gồm ba đoạn base64url — một header, một payload JSON và một chữ ký. Chữ ký có thể là HMAC hoặc RSA/ECDSA. Payload thường chứa user id, iat (issued at), exp (expiration), jti (JWT ID), và có thể là một số phạm vi (scopes).

Lời quảng cáo là: máy chủ ký nó, client mang nó, mọi yêu cầu tiếp theo chỉ cần xác minh chữ ký — không cần truy vấn database. Đó là xác thực vô trạng thái. Đó là toàn bộ giá trị mà JWT mang lại. Nếu lấy đi thuộc tính đó, JWT chỉ là một token mờ (opaque token) khoác lên mình bộ cánh cầu kỳ.

Vì vậy, hãy gỡ bỏ nó đi. Chỉ cần một câu hỏi.

Bạn không thể thu hồi (invalidate) một JWT. Bạn thực sự không thể.

Làm thế nào để bạn đăng xuất người dùng trước khi token hết hạn (exp)?

Bạn không thể. Đó là câu trả lời. Token hợp lệ cho đến khi nó hết hạn, điểm hết. Cách duy nhất để vô hiệu hóa nó là lưu trữ jti ở phía máy chủ trong danh sách thu hồi và kiểm tra danh sách đó trên mọi yêu cầu. Đó là một tra cứu database. Đó là điều mà JWT được cho là giúp bạn bỏ qua. Chúc mừng, bạn đã phát minh lại session, nhưng tồi tệ hơn.

Vì vậy, bạn có hai nước đi thua cuộc:

  1. Không thu hồi: Một token bị đánh cắp sẽ hợp lệ cho đến khi hết hạn. Và vì không ai muốn "buộc người dùng phải đăng nhập lại" — tôi đã từng thấy các token có thời hạn 1 năm, 5 năm và thậm chí 10 năm trong môi trường production — kẻ tấn công sẽ sở hữu tài khoản trong toàn bộ khoảng thời gian đó. Nút "đăng xuất ở khắp mọi nơi" của bạn là một lời nói dối. Việc đặt lại mật khẩu không thực sự đá ai ra ngoài.
  2. Duy trì danh sách thu hồi: Bây giờ mọi yêu cầu đều thực hiện xác minh chữ ký cộng với một tra cứu database hoặc Redis. Bạn phải trả cả hai chi phí. Bạn đã chấp nhận mọi sự phức tạp của JWT để giữ lại mọi chi phí của session.

Không có lựa chọn thứ ba. Không bao giờ có.

Refresh token là sự thú tội

Phản ứng hoảng loạn tiêu chuẩn cho "JWT tồn tại lâu dài rất nguy hiểm" là: sử dụng access token tồn tại ngắn hạn và một refresh token. Hãy đọc kỹ câu đó. Nó nói rằng thứ chúng tôi bán cho bạn không an toàn, vì vậy đây là thứ thứ hai để vá nó.

Điều bạn hiện phải triển khai trên mọi client:

  • Đính kèm access token vào mọi yêu cầu.
  • Phát hiện 401 hoặc sắp hết hạn.
  • Gọi /auth/refresh với refresh token.
  • Thử lại yêu cầu gốc với access token mới.
  • Xử lý trường hợp làm mới thất bại (đăng xuất bắt buộc, chuyển hướng, phát lại hàng đợi).
  • Lưu trữ an toàn cho hai token thay vì một.

Và trên máy chủ, bạn lưu refresh token trong database để thu hồi. Vậy nên bạn đã có trạng thái (stateful) rồi. Bạn đã xây dựng tất cả những điều này — trên web, iOS, Android và mọi bên thứ ba tích hợp với bạn — để tránh điều mà bạn cuối cùng vẫn phải làm anyways. Mọi team client giờ đây tốn cả một sprint để loay hoay với token thay vì xuất tính năng sản phẩm.

Một token mờ duy nhất, được tra cứu trong Redis với Postgres làm lưu trữ phụ, mang lại cho bạn cùng mức bảo mật chỉ trong một dòng middleware. Không cần refresh. Không cần token thứ hai. Không cần vòng lặp thử lại.

Chi phí mỗi yêu cầu là có thật và mọi người nói dối về nó

Cái khoát tay tiêu chuẩn là xác minh chữ ký là "miễn phí cơ bản". Nó không miễn phí.

Các mức độ lớn sơ bộ mỗi yêu cầu trên một máy x86 hiện đại:

  • HS256 (HMAC): ~5 µs
  • RS256 (RSA 2048-bit): ~80–150 µs
  • ES256 (ECDSA): ~40–80 µs
  • Redis GET: ~100–300 µs (localhost, một vòng lặp)

Xác minh RS256 cùng cấp độ với một tra cứu Redis. Và bạn vẫn phải phân tích cú pháp JSON, xác thực exp, iss, aud, đi bộ trong bộ nhớ đệm JWKS để tìm đúng khóa và phân bổ vài đối tượng cho GC dọn dẹp sau đó. Hãy nhân với tốc độ yêu cầu của bạn.

"JWT giúp bạn tiết kiệm lần truy cập database" hóa ra là mostly false ngay khi bạn đo đạc. Một kiểm tra token mờ là băm, GET từ Redis, xong — với một LRU trong quá trình phía trước nếu bạn thực sự quan tâm. Bạn không tiết kiệm tài nguyên tính toán bằng cách dùng JWT. Bạn đang chi tiêu nhiều hơn, cho thứ kém an toàn hơn, thứ mà bạn không thể thu hồi.

"Xác minh" trên frontend mà chưa ai từng triển khai thành công

Lời quảng cáo JWT bất đối xứng còn thêm một chân: máy chủ xuất bản khóa công khai, client (hoặc gateway, hoặc bên thứ ba) xác minh chữ ký cục bộ, và máy chủ xác thực giữ ra khỏi đường đi yêu cầu. Nếu bạn thực sự làm điều này, bạn tiết kiệm được các chuyến đi khứ hồi.

Hầu như không ai làm điều này. Các nền tảng lớn với team danh tính chuyên biệt thì có. Ứng dụng Laravel bạn sẽ triển khai tháng sau thì không. WebCrypto không dùng được khi JWT trở nên thời thượng, vì vậy các frontend đã gửi atob(token.split('.')[1]), phân tích JSON, tin bất kỳ thứ gì ở bên trong, và coi như xong. Khi access token hết hạn, lệnh gọi API tiếp theo trả về 401, SPA bật lại đến /login, và máy chủ vẫn bị truy cập anyway. Lợi ích "stateless" chỉ tồn tại trên slide thuyết trình.

Và đây là phần khiến bạn tức giận: nếu bạn đưa JWT cho một bên tích hợp thứ ba và họ bỏ qua việc xác minh khóa công khai — và họ sẽ làm, vì ai cũng thế — thì mọi yêu cầu của họ trở thành một yêu cầu thông qua backend xác thực của bạn theo đường vòng dài. Bạn chịu chi phí cho sự lười biếng của họ, mãi mãi. Với token mờ, điều này chỉ là... cách nó hoạt động. Không có sự không khớp, không có thuế ẩn, không có câu hỏi "họ có thực hiện các kiểm tra đúng không" để mất ngủ.

JWT mã hóa (JWE) càng vô lý hơn

Nếu bạn mã hóa payload vì nó chứa dữ liệu nhạy cảm, bạn cũng đã quyết định rằng client không thể đọc nó. Vậy thì nó làm gì ở trong token? Client sẽ gọi /api/me để hiển thị UI anyway. Đưa dữ liệu phía sau endpoint đó và để token tham chiếu đến nó. JWE giải quyết một vấn đề mà bạn tự tạo ra bằng cách nhồi dữ liệu người dùng vào thông tin xác thực.

Và trong khi chúng ta đang nói về chuyện này: nếu payload JWT trở thành nguồn sự thật cho "người dùng này là ai", người dùng cập nhật email của họ, vai trò của họ bị thu hồi, gói đăng ký của họ bị hạ cấp — và ứng dụng của bạn vẫn đọc các giá trị cũ kỹ từ token cho đến lần làm mới tiếp theo. Người dùng gửi vé hỗ trợ vì ứng dụng "không cập nhật". Bạn sẽ sửa nó bằng cách lấy người dùng từ database trên mọi yêu cầu. Bạn lại có trạng thái rồi. Bạn luôn sẽ như vậy.

Đây là gợi ý cuối cùng phá hủy trò chơi.

Lời khuyên là: đừng đặt JWT vào localStorage vì XSS sẽ đánh cắp nó — hãy đặt nó vào cookie httpOnly, Secure, SameSite để JavaScript không thể chạm vào. Đọc câu đó thêm một lần nữa. Bạn vừa mô tả một session cookie. Trình duyệt tự động đính kèm nó, máy chủ đọc nó trên mọi yêu cầu, client không thể thấy bên trong cái gì.

Điều duy nhất phân biệt nó với một session cookie bình thường là các byte ở bên trong tình cờ là một JWT đã ký thay vì một ID ngẫu nhiên. Và vì trình duyệt không thể đọc những byte đó, tính năng "bạn có thể giải mã nó phía client" — vốn là một tính năng không ai dùng — giờ đây vật lý là không thể. Bạn đã giữ mọi dòng phức tạp của JWT (ký, hết hạn, làm mới, JWKS) và từ bỏ thuộc tính duy nhất từng khiến JWT khác với session.

Đó là nó. Toàn bộ lâu đài bài lá đó. Nếu bạn đặt JWT vào httpOnly cookie, bạn đang chạy một phiên có trạng thái với mật mã bổ sung. Dừng lại lại. Chỉ cần chạy session.

"Không hệ thống nào là vô trạng thái" — xin hãy ngừng giả vờ

Hệ thống của bạn có người dùng. Hệ thống của bạn có session. Hệ thống của bạn có giới hạn tốc độ, nhật ký kiểm toán, quyền hạn, trạng thái thanh toán, thiết bị, khóa tài khoản, tín hiệu lạm dụng, cờ tính năng. Mỗi thứ trong số đó là trạng thái phía máy chủ. Đọc hàng session cạnh hàng người dùng trong cùng một truy vấn không tốn bạn bao nhiêu. Giải thưởng kiến trúc của "tính vô trạng thái" chỉ quan trọng nếu mọi thứ khác trong đường đi yêu cầu của bạn cũng vô trạng thái — và trong một ứng dụng thực, nó không phải và sẽ không bao giờ là.

Okta đã có bài nói "Why JWTs Are Bad for Authentication" và lập luận kỹ điểm này, và Okta là một công ty bán JWT cho bạn sống. Khi nhà cung cấp bảo bạn ngừng sử dụng mẫu cờ hiệu của họ cho ứng dụng trình duyệt bên party thứ nhất, có lẽ nên lắng nghe.

Thay vào đó hãy triển khai cái gì?

Đối với ứng dụng web bên party thứ nhất:

  • Session cookie httpOnly, Secure, SameSite=Lax. Một ID ngẫu nhiên mờ 256-bit. Xong.
  • Bảng Sessions. id, user_id, created_at, last_seen_at, user_agent, ip, revoked_at.
  • Redis phía trước khóa theo băm ID session. TTL ~60s hoặc vô hiệu hóa khi ghi.
  • Logout: UPDATE sessions SET revoked_at = NOW() WHERE id = $1, cộng DEL trong Redis.
  • Đăng xuất cưỡng bức mọi thiết bị: cùng UPDATE với user_id = $1. Tính năng mà ứng dụng JWT của bạn giả vờ có, ứng dụng này thực sự có.

Đối với API được tiêu thụ bởi mobile hoặc tích hợp:

  • Opaque bearer tokens. Tiền tố kiểu GitHub xxx_ để chúng có thể grep trong log và thu hồi bởi dịch vụ quét bí mật.
  • Cùng bảng. Thêm scopes, expires_at, last_used_at.
  • Một bảng điều khiển admin để liệt kê, thu hồi, tái tạo, hết hạn. Điều bạn lẽ ra nên xây dựng cho hệ thống JWT của mình nhưng không làm.
  • Authorization: Bearer. Tài liệu hóa một lần. Mọi client HTTP trên Trái Đất đã nói ngôn ngữ này rồi.

Bạn nhận được mọi thứ JWT hứa hẹn — phát hành, thu hồi, hết hạn, phạm vi, kiểm toán — mà không cần ký, xoay vòng JWKS, điệu nhảy refresh, các cạm bẫy mã hóa, và lớp CVE bắt đầu bằng alg: none.

Nó không phải là gì

Nó không phải là "JWT bị lỗi". Mật mã học thì ổn. Gói jwt-auth thì ổn. Khái niệm sử dụng JWT làm session của ứng dụng mới là thứ bị sai.

Nó không phải là sự phản đối việc có điều kiện với các token đã ký. JWT đã ký tồn tại ngắn hạn cho các lệnh gọi service-to-service bên trong một mesh tin cậy, hoặc như ID token trong OIDC, là chính đáng. Cả hai đều hẹp.

Nó không phải là tuyên bố các token mờ là miễn phí. Bạn trả cho tra cứu Redis. Bạn sẽ phải trả cho nó ngay khi bạn muốn đăng xuất người dùng.

Nó không áp dụng cho liên kết thực sự (true federation). Nếu bạn là Okta, Auth0, hoặc chạy một nhà cung cấp danh tính mà token được tiêu thụ bởi hàng trăm máy chủ tài nguyên mà bạn không vận hành, JWT chứng minh giá trị của nó. Bạn không phải họ.

Nếu bạn bắt đầu một ứng dụng hôm nay, hãy mặc định dùng session cookie hoặc một bearer token mờ trong database, được lưu trong cache Redis. Chỉ dùng JWT khi bạn có thể chỉ ra một yêu cầu liên kết thực sự cần nó.

Chia sẻ:FacebookX
Nội dung tổng hợp bằng AI, mang tính tham khảo. Xem bài gốc ↗