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.

Nếu bạn chọn JWT để "không phải lưu trạng thái", rồi thêm danh sách đen để thu hồi được — bạn đang trả chi phí của cả hai cách mà không được ưu điểm của cách nào.

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.