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.
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.
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ý".
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á.
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. Đâ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.
Thử 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.
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.