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.

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 — 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.