Hình dung JWT như một chiếc vòng đeo tay lễ hội có dán niêm. Nó được niêm để không ai sửa được (chữ ký = tính toàn vẹn), nhưng chữ in trên đó thì ai cũng đọc được (không mã hoá), và một khi cổng đã phát ra thì không với tay vào túi bạn mà thu lại được — nó có giá trị tới đúng cái ngày in trên vòng (không thu hồi). Đối lại, session giống một tấm vé gửi đồ: bản thân tấm vé vô nghĩa, thông tin thật nằm ở quầy (máy chủ), nên quầy xé cái là huỷ được ngay. JWT là mặc định của nhiều dự án mới, thường vì lý do sai. Bài này đo nó để chọn cho đúng.
Nó không mã hoá gì cả
header : {"alg":"HS256"}
payload : {"iss":"ung-dung-cua-toi","sub":"an","exp":1786614770,"iat":1786614170,"vai":"USER"}
chữ ký : BiT0d3ENAG5heLEtvUNv... (43 ký tự)
Payload là Base64, không phải mã hoá. Ai cầm token cũng đọc được nội dung — dán vào jwt.io là xong, đúng như đọc chữ in trên vòng đeo tay.
Nên đừng bao giờ để dữ liệu nhạy cảm trong JWT: số điện thoại, địa chỉ, vai trò nội bộ, id bản ghi mà bạn không muốn lộ. Token nằm trong localStorage, trong log proxy, trong lịch sử trình duyệt.
JWT đảm bảo tính toàn vẹn (không sửa được), không đảm bảo tính bí mật.
Bốn kiểu giả mạo bị chặn
token hợp lệ OK sub=an vai=USER
token hết hạn (1 giây trước) TỪ CHỐI: expired
sửa vai -> ADMIN, giữ chữ ký TỪ CHỐI: Signed JWT rejected: Invalid signature
alg: none, bỏ chữ ký TỪ CHỐI: Unsupported algorithm of none
ký bằng khoá khác TỪ CHỐI: Invalid signature
Dòng thứ ba là điều JWT tồn tại để làm: sửa một byte trong payload là chữ ký không khớp — cái niêm rách.
Dòng thứ tư đáng nói riêng. Tấn công alg: none là lỗ hổng kinh điển của JWT: kẻ tấn công đổi thuật toán trong header thành none, bỏ chữ ký, và một thư viện cài đặt cẩu thả sẽ chấp nhận vì "token này khai là không cần chữ ký" — như gã làm giả chìa ra một chiếc vòng bảo "loại này khỏi cần niêm, cứ tin tôi".
NimbusJwtDecoder của Spring Security từ chối thẳng. Nhưng bài học vẫn còn nguyên giá trị: đừng tự viết phần xác minh, và đừng để thuật toán do token quyết định.
Chi phí
ký x10000: 62 ms (0,006 ms/lần)
xác minh x10000: 116 ms (0,012 ms/lần)
Xác minh HS256 gần như miễn phí — 12 micro giây. Với 10.000 request mỗi giây, đó là 0,12 giây CPU.
Đây là lập luận đúng cho JWT: xác minh không cần đi tới CSDL hay Redis. So với bài 37, nơi một lượt Redis tốn 0,327 ms, JWT nhanh hơn 27 lần.
Nhưng lưu ý: đó là HS256 (khoá đối xứng). RS256 chậm hơn đáng kể, và với hệ thống dùng khoá công khai, bạn còn phải tải và cache JWKS.
Cái giá: kích thước
JWT này : 177 byte, gửi kèm MỖI request
ID phiên: 32 byte
Và 177 byte là token tối thiểu — chỉ có sub, exp, iat, iss, một claim. Token thật với danh sách quyền thường 500–1500 byte.
Nhân với mọi request, mọi tài nguyên tĩnh nếu bạn gửi kèm không chọn lọc, mọi người dùng. Trên mạng di động, đó là chi phí có thật.
Vấn đề thật sự: không thu hồi được
Đây là điều quan trọng nhất, và nó không phải chuyện hiệu năng.
Token đã ký thì hợp lệ cho tới khi hết hạn. Người dùng đổi mật khẩu, bạn khoá tài khoản, bạn phát hiện token bị đánh cắp — token vẫn dùng được, vì máy chủ không giữ trạng thái gì để mà xoá. Cổng đã phát vòng ra rồi, không thu lại được.
Ba cách vá, và cả ba đều làm mất chính ưu điểm của JWT:
Hạn ngắn cộng refresh token. Access token sống 5–15 phút, refresh token lưu trong CSDL và thu hồi được. Đây là cách chuẩn — nhưng giờ bạn có trạng thái ở phía máy chủ, chỉ là ít hơn.
Danh sách đen. Kiểm mỗi token với Redis. Bạn vừa thêm lại đúng lượt đi về mà JWT định tránh.
Số phiên bản token trong CSDL. Cũng là một lượt đọc mỗi request.
Ba câu hỏi trước khi chọn JWT
Có nhiều dịch vụ cần xác minh danh tính không? Nếu có, JWT thắng rõ: mỗi dịch vụ xác minh cục bộ, không gọi về dịch vụ xác thực — nhiều cổng ở lễ hội, mỗi cổng tự soi niêm mà không cần gọi về quầy trung tâm. Đây là lý do chính đáng nhất.
Có client không phải trình duyệt không? Di động, dịch vụ khác, CLI — chúng không có cookie, và JWT tiện hơn nhiều.
Cần đăng xuất ngay lập tức tới mức nào? Nếu "khoá tài khoản là phải có hiệu lực ngay", session thắng.
Với một ứng dụng web, một máy chủ, người dùng dùng trình duyệt — session cộng cookie đơn giản hơn, an toàn hơn, và thu hồi được ngay. Đó là phần lớn dự án, và JWT thường được chọn vì nó hiện đại chứ không vì nó phù hợp.
Cấu hình trong Spring
@Bean SecurityFilterChain chuoi(HttpSecurity http) throws Exception {
return http
.securityMatcher("/api/**")
.csrf(c -> c.disable()) // không cookie -> không cần CSRF (bài 42)
.sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()))
.authorizeHttpRequests(a -> a.anyRequest().authenticated())
.build();
}
@Bean JwtDecoder giaiMa() {
return NimbusJwtDecoder.withSecretKey(khoa)
.build();
}
SessionCreationPolicy.STATELESS là dòng dễ quên: không có nó, Spring vẫn tạo session cho mỗi request, và bạn có một HttpSession chết cho mỗi lời gọi API.
Ánh xạ claim sang quyền:
@Bean JwtAuthenticationConverter chuyenDoi() {
var g = new JwtGrantedAuthoritiesConverter();
g.setAuthorityPrefix("ROLE_");
g.setAuthoritiesClaimName("vai");
var c = new JwtAuthenticationConverter();
c.setJwtGrantedAuthoritiesConverter(g);
return c;
}
Nhớ bài 41: hasRole("ADMIN") tìm quyền ROLE_ADMIN, nên tiền tố phải khớp.
Năm điều bắt buộc
Kiểm exp, iss, và aud. Spring kiểm exp sẵn; hai cái kia phải khai. Không kiểm aud nghĩa là token cấp cho dịch vụ khác vẫn dùng được ở dịch vụ của bạn.
Ghim thuật toán. Đừng để header của token quyết định.
Khoá HS256 tối thiểu 32 byte. Ngắn hơn là brute-force được, và Nimbus từ chối thẳng.
Đừng lưu token trong localStorage. Bất kỳ XSS nào cũng đọc được. Cookie HttpOnly + SameSite an toàn hơn — nhưng khi đó bạn cần CSRF trở lại, vì cookie được gửi tự động.
Hạn ngắn. 15 phút cho access token là điểm cân bằng thông thường.
Nếu muốn tự soi trong ba mươi giây, lấy một JWT thật của ứng dụng bạn và giải mã phần giữa:
echo '<phần-giữa-của-token>' | base64 -d
Nhìn xem có gì trong đó mà bạn không muốn người dùng — hoặc bất kỳ ai đọc được log — nhìn thấy. Và kiểm exp: nếu hạn tính bằng ngày, thì mất token nghĩa là mất tài khoản trong ngần ấy ngày.
Mẫu số chung
Bỏ qua chữ viết tắt, lựa chọn ở đây là một lựa chọn muôn thuở: token tự chứa (bearer) hay tham chiếu mờ tới trạng thái máy chủ. JWT là một capability tự mô tả, tự xác minh — chiếc vòng đeo tay. Session ID là một con trỏ mờ tới bản ghi ở máy chủ — tấm vé gửi đồ. Cùng một cặp đánh đổi đó lặp lại khắp nơi: API key, access token OAuth (thường chính là JWT), assertion SAML, vé Kerberos (đã giải đúng bài toán này từ mấy chục năm trước), cookie đã ký của Rails/Django/Flask.
Hai điều đáng khắc sâu.
Một: bạn không thể vừa phi-trạng-thái vừa thu-hồi-tức-thì với giá rẻ. Token tự chứa thì xác minh ở đâu cũng được, không cần tra cứu — nhưng không gọi về được; tham chiếu mờ thì một lượt tra, đổi lại huỷ được ngay. Vấn đề thu hồi là bản chất của token phi trạng thái, và mọi cách vá (hạn ngắn + refresh, danh sách đen) đều nhét trạng thái trở lại. Hạn-ngắn là thoả hiệp của cả ngành — đúng cái Kerberos làm để chặn cửa sổ rủi ro.
Hai: toàn vẹn và bí mật là hai tính chất khác nhau — chữ ký chứng minh không bị sửa, không chứng minh được giấu kín; nên đừng bao giờ bỏ bí mật vào thứ chỉ được ký, và đừng để token tự chọn thuật toán xác minh nó (alg: none, nhầm lẫn khoá RS256→HS256). Sợi chỉ chung: quyết định niềm tin sống ở đâu trước khi chọn cơ chế — trong một token tự xác minh (mở rộng tốt, không gọi lại được) hay trong trạng thái máy chủ (một lượt tra, huỷ được ngay); và nếu bạn thấy mình đang chắp cơ chế thu hồi lên một token phi trạng thái, đó là hệ thống đang nói với bạn rằng session mới là mô hình đúng.
Ngày mai: OAuth2 và đăng nhập một lần — ba luồng, và cái duy nhất còn đúng năm 2026.