Phần 9 dừng ở lúc người dùng đăng nhập thành công. Việc tiếp theo là cấp cho họ một thứ để mang theo trong các request sau mà không phải băm mật khẩu lại mỗi lần, và JSON Web Token là cách phổ biến nhất. Một JWT tự chứa: nó mang thông tin người dùng trong payload và một chữ ký để server tin rằng payload không bị sửa. Ba câu hỏi vận hành đi kèm: ký và xác thực mỗi thuật toán tốn bao nhiêu, vì xác thực chạy ở mọi request của cổng API; token nặng bao nhiêu trên dây, vì nó đi trong mọi request; và hai lỗ hổng JWT nổi tiếng nhất, alg: none và nhầm loại khoá, thư viện Go có chặn không. Bài này đo cả ba với golang-jwt/jwt v5, rồi dựng cơ chế thu hồi mà JWT vốn không có sẵn.
Bốn thuật toán, ba phép đo

Hình 1: Bốn thuật toán ký JWT và khác biệt khoá đối xứng so với bất đối xứng, ba phép đo A, B, C, và lý do đo cả cỡ lẫn tốc độ.
Một JWT là ba phần ngăn bằng dấu chấm: header, payload, chữ ký, mỗi phần mã hoá base64url. Header nói thuật toán, payload mang các claim, chữ ký là phần server tạo và kiểm. Khác biệt lớn nằm ở thuật toán. HS256 dùng HMAC-SHA256 với một khoá đối xứng duy nhất: ai xác thực được token cũng ký được token, nên nó chỉ hợp khi một dịch vụ vừa cấp vừa kiểm. RS256 dùng RSA-2048: khoá riêng ký, khoá công khai kiểm, nên có thể phát khoá công khai cho nhiều dịch vụ kiểm mà không dịch vụ nào ký giả được. ES256 là ECDSA P-256, cùng mô hình bất đối xứng nhưng chữ ký và khoá nhỏ hơn nhiều. EdDSA là Ed25519, chữ ký bất đối xứng hiện đại. Cùng bảy claim, bài này đo cỡ token và tốc độ của từng loại.
Ba phép đo. A: một luồng trong 2 giây, số lần ký và xác thực mỗi giây, cỡ ba phần của token, rồi xác thực trên 10 lõi để lấy cận trên cho cổng API. B: hai cấu hình sai kinh điển, kiểm xem v5 có từ chối khi server ghim thuật toán bằng WithValidMethods không. Thứ nhất là alg: none, token có header ghi thuật toán none, không chữ ký, payload sửa quyền thành admin. Thứ hai là nhầm loại khoá: token ký bằng HS256 với chính khoá công khai RSA làm khoá HMAC, thử xem server đáng lẽ dùng RS256 có bị lừa dùng khoá công khai làm bí mật HMAC không. C: thu hồi, vì JWT tự chứa và server không giữ trạng thái nên không "xoá" được token đã cấp; cách làm là một danh sách chặn ngắn hạn theo claim jti với TTL. Đo cả cỡ lẫn tốc độ vì token đi trong mọi request qua header Authorization, nên cỡ nhân số request là băng thông; và ký thường chỉ một lần lúc đăng nhập, còn xác thực chạy mỗi request ở cổng API, nên tốc độ xác thực mới là con số quyết định.
Đo thật: HS256 ký nhanh 300 lần, nhưng verify mới là việc mỗi request

Hình 2: Cỡ token và tốc độ ký/xác thực của bốn thuật toán, kết quả hai cấu hình sai, và cơ chế thu hồi bằng deny-list.
| thuật toán | token | chữ ký | ký/giây | xác thực/giây |
|---|---|---|---|---|
| HS256 | 255 B | 43 B | 394.557 | 312.743 |
| RS256 | 554 B | 342 B | 1.321 | 30.334 |
| RS256-4k | 895 B | 683 B | 217 | 8.373 |
| ES256 | 298 B | 86 B | 62.966 | 24.204 |
| EdDSA | 298 B | 86 B | 50.658 | 24.323 |
HS256 ký nhanh hơn RS256 gần 300 lần, nhưng con số đáng nhìn là xác thực. HS256 ký 394.557 lần mỗi giây, RS256 chỉ 1.321: HMAC chỉ là vài phép băm, còn RSA ký là một phép luỹ thừa với khoá riêng, đúng 757 µs mà phần 2 và phần 4 đã thấy với chữ ký RSA trong TLS. Nhưng ký chỉ xảy ra một lần lúc đăng nhập; việc lặp lại mỗi request là xác thực. HS256 xác thực 312.743 lần mỗi giây, RS256 30.334: chênh 10 lần, vì verify RSA rẻ hơn ký RSA nhiều nhưng vẫn đắt hơn HMAC. Trên 10 lõi, HS256 đạt 607.868 và RS256 113.651 lần mỗi giây; cả hai đều thừa cho hầu hết API, nhưng nếu cổng API xác thực mọi request và chịu tải cao, 113.000 so với 608.000 là khác biệt giữa một lõi và sáu lõi chỉ để kiểm token. ES256 và EdDSA nằm giữa, verify quanh 24.000 lần mỗi giây một luồng, chậm hơn RS256 ở verify vì ECDSA verify không rẻ như RSA verify, nhưng ký nhanh hơn RSA rất nhiều.
RS256 làm token nặng gấp đôi, RSA-4096 gần gấp bốn. Cùng payload 174 byte, token HS256 là 255 byte với chữ ký 43 byte; RS256 là 554 byte vì chữ ký RSA-2048 chiếm 342 byte; RSA-4096 lên 895 byte với chữ ký 683 byte. ES256 và EdDSA chỉ 298 byte, chữ ký 86 byte, nhỏ nhất trong nhóm bất đối xứng. Token đi trong header Authorization của mọi request, nên khác biệt 255 so với 895 byte nhân với hàng triệu request là băng thông thật, và quan trọng hơn, một số proxy giới hạn kích thước header, nơi token RSA-4096 cộng vài header khác có thể vượt ngưỡng 8 KB mặc định. Nếu cần bất đối xứng để phát khoá công khai, EdDSA cho token nhỏ như ES256 với ký nhanh hơn, là lựa chọn đáng cân nhắc hơn RS256 cho hệ thống mới.
Cả hai lỗ hổng kinh điển đều bị v5 chặn, miễn là server ghim thuật toán. alg: none là lỗ hổng mà một token không chữ ký được chấp nhận vì thư viện tin vào trường alg trong header do kẻ tấn công kiểm soát; ở đây token có payload sửa thành admin và không chữ ký, và jwt.Parse với WithValidMethods([]string{"HS256", "RS256"}) trả về signing method none is invalid, từ chối. Nhầm loại khoá tinh vi hơn: kẻ tấn công lấy khoá công khai RSA của server, thứ công khai theo định nghĩa, ký một token bằng HS256 dùng chính chuỗi PEM đó làm khoá HMAC; nếu server đáng lẽ dùng RS256 lại chấp nhận HS256 và nạp khoá công khai làm khoá HMAC, chữ ký khớp và token giả được nhận. v5 với WithValidMethods([]string{"RS256"}) trả signing method HS256 is invalid, từ chối. Token giả chỉ lọt khi server được cấu hình sai có chủ ý: cho phép cả HS256 lẫn RS256 và callback nạp khoá chọn theo t.Method. Bài học một dòng là luôn gọi WithValidMethods với đúng một họ thuật toán, và không bao giờ để callback chọn khoá dựa vào thuật toán do token khai; lỗ hổng nằm ở code ứng dụng, không ở thư viện v5.
JWT không thu hồi được, nên thu hồi là một danh sách chặn ngắn hạn. Điểm mạnh của JWT là server không cần tra CSDL để xác thực; điểm yếu đi kèm là server không có nút nào để vô hiệu một token đã cấp trước khi nó hết hạn. Cách thực tế là một deny-list theo claim jti, mã định danh duy nhất của token: khi thu hồi, thêm jti vào danh sách với TTL bằng thời gian token còn sống. Đo cho thấy: token thu hồi vẫn có chữ ký hợp lệ, true, nhưng jti nằm trong deny-list, nên bị chặn. Điểm mấu chốt về kích thước: vì mỗi mục chỉ cần nhớ tới lúc token hết hạn, danh sách lớn tối đa bằng số thu hồi mỗi phút nhân tuổi thọ token, không phải mọi token từng cấp. Với token 15 phút và 100 thu hồi mỗi phút, đó là 1.500 mục, một tập Redis nhỏ. Mô hình thường dùng là access token ngắn, 15 phút, cộng refresh token dài; thu hồi nghĩa là từ chối refresh và đợi access token hết hạn tự nhiên, nên cửa sổ rủi ro tối đa bằng tuổi access token.
Đánh đổi cần cân nhắc
Chọn thuật toán theo ai cần verify, không theo tốc độ. HS256 nhanh nhất ở cả hai chiều và token nhỏ, nhưng mọi dịch vụ verify đều phải giữ khoá bí mật, và một dịch vụ bị xâm nhập là mọi token bị giả được; nó hợp cho một monolith hoặc một cổng API duy nhất. Khi nhiều dịch vụ cần verify mà không được phép ký, như kiến trúc nhiều microservice hay OIDC, cần bất đối xứng, và EdDSA cho token nhỏ nhất với ký nhanh; RS256 chỉ nên giữ khi phải tương thích hệ thống cũ. Tốc độ verify 24.000 đến 113.000 lần mỗi giây một đến mười lõi là đủ cho gần như mọi tải thực.
TTL ngắn thay cho thu hồi phức tạp. Deny-list hoạt động nhưng thêm một tra cứu trạng thái vào mỗi request, làm mất một phần lợi ích tự chứa của JWT. Nhiều hệ thống chọn access token rất ngắn và không có deny-list, chấp nhận rằng một token bị lộ còn dùng được tối đa vài phút; đó là đánh đổi đơn giản hơn và thường đủ. Deny-list chỉ thật sự cần khi phải vô hiệu tức thì, ví dụ khi phát hiện tài khoản bị chiếm.
Phạm vi đo. Máy ảo aarch64 10 vCPU chia sẻ; tốc độ tuyệt đối sẽ khác trên x86 với lệnh tăng tốc RSA, nhưng tỉ lệ giữa các thuật toán ổn định. Chưa đo xác thực claim đầy đủ, exp, nbf, iss, aud, vốn phải luôn bật và v5 làm mặc định cho exp; chưa đo JWKS với nhiều khoá và xoay khoá ký, nơi kid trong header chọn khoá, một chỗ khác cần cẩn thận không để token chọn khoá tuỳ ý. Và JWT mã hoá, JWE, không bàn ở đây: payload JWT chỉ được ký, không được giấu, nên đừng bao giờ đặt bí mật vào claim.
Ba ý mang về
- Xác thực là con số quyết định, không phải ký: HS256 313.000 so với RS256 30.000 lần mỗi giây một luồng. Ký xảy ra một lần lúc đăng nhập; verify chạy mỗi request. Token RS256 cũng nặng gấp đôi, EdDSA cho token nhỏ nếu cần bất đối xứng.
alg=nonevà nhầm loại khoá đều bị v5 chặn khi ghim thuật toán bằngWithValidMethods. Token giả chỉ lọt khi code cho nhiều thuật toán và chọn khoá theot.Method; lỗ hổng ở ứng dụng, không ở thư viện.- Thu hồi bằng deny-list theo
jtivới TTL bằng tuổi token. Kích thước là số thu hồi mỗi phút nhân tuổi token, không phải mọi token từng cấp; hoặc đơn giản hơn, dùng access token rất ngắn và bỏ deny-list.
Nguồn
github.com/golang-jwt/jwt/v5—Parse,ParseWithClaims,WithValidMethods,SigningMethod: https://pkg.go.dev/github.com/golang-jwt/jwt/v5- RFC 7519 — JSON Web Token (JWT), claim
exp,nbf,jti: https://www.rfc-editor.org/rfc/rfc7519 - RFC 7518 — JSON Web Algorithms (JWA), HS256, RS256, ES256, EdDSA và cảnh báo về
none: https://www.rfc-editor.org/rfc/rfc7518 - Auth0 — Critical vulnerabilities in JSON Web Token libraries (alg confusion, key confusion): https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/
- OWASP — JSON Web Token for Java Cheat Sheet, ghim thuật toán và xác thực claim: https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html
- RFC 8032 — Edwards-Curve Digital Signature Algorithm (EdDSA): https://www.rfc-editor.org/rfc/rfc8032
Phần sau rời khỏi mật mã để tới một kiểu tấn công không cần phá gì cả: slowloris. Một client gửi header từng byte một, giữ kết nối mở thật lâu, và một server Go không đặt timeout bị chiếm goroutine cùng file descriptor tới mức không nhận được client thật. Đo số kết nối cần để làm nghẽn, rồi thêm ReadHeaderTimeout, IdleTimeout và giới hạn kết nối mỗi IP và đo lại.