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

Ảnh chụp đoạn mã nền tối JWT bốn thuật toán hai bẫy và chuyện thu hồi với Go 1.23 github com golang-jwt jwt v5 go-lab aarch64 10 vCPU cùng 7 claim sub iss aud scope iat exp jti, một JWT bằng header chấm payload chấm chữ ký khác nhau ở thuật toán ký, HS256 HMAC-SHA256 một khoá đối xứng ai xác thực được cũng ký được chỉ cho dịch vụ tự tin tự ký, RS256 RSA-2048 PKCS#1 khoá riêng ký khoá công khai xác thực phát cho nhiều bên verify không ký được, ES256 ECDSA P-256 như RS256 nhưng chữ ký và khoá nhỏ hơn nhiều, EdDSA Ed25519 chữ ký bất đối xứng hiện đại nhanh và nhỏ, mục tiêu cùng payload đo cỡ token trên dây và tốc độ ký xác thực của từng loại, ba phép đo A tốc độ 1 luồng 2 giây ký mỗi giây và xác thực mỗi giây cỡ 3 phần của token rồi xác thực 10 lõi cổng API, B hai cấu hình sai kinh điển v5 có từ chối khi server ghim thuật toán bằng WithValidMethods không 1 alg none token header ghi none không chữ ký payload sửa thành admin 2 nhầm loại khoá token ký HS256 bằng chính khoá công khai server ghim RS256 có bị lừa không, C thu hồi JWT không tự thu hồi được server không giữ trạng thái danh sách chặn ngắn hạn theo jti cộng TTL, vì sao đo cả cỡ token lẫn tốc độ token đi trong mọi request header Authorization cỡ nhân với số request là băng thông và cả giới hạn header ký thường một lần lúc đăng nhập xác thực mỗi request ở cổng API tốc độ xác thực là con số quyết định HS256 verify nhanh nhất nhưng đòi mọi dịch vụ verify đều giữ khoá bí mật RS256 verify chậm hơn khoá công khai phát được

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

Ảnh chụp output thật nền tối HS256 ký nhanh 300 lần RS256 verify mới là nút thắt với go-lab Go 1.23.12 golang-jwt v5 1 luồng 2 giây mỗi phép payload 174 byte cho mọi thuật toán, A cỡ token và tốc độ 1 luồng thuật toán token chữ ký ký mỗi giây xác thực mỗi giây HS256 255 byte 43 byte 394557 ký 2,5 micro giây 312743 xác thực 3,2 micro giây, RS256 554 byte 342 byte 1321 ký 757 micro giây 30334 xác thực 33 micro giây ký chậm 300 lần verify chậm 10 lần, RS256-4k 895 byte 683 byte 217 ký 4,6 ms 8373 xác thực 119 micro giây RSA-4096 token to verify đắt, ES256 298 byte 86 byte 62966 ký 16 micro giây 24204 xác thực 41 micro giây token nhỏ nhất của nhóm bất đối xứng, EdDSA 298 byte 86 byte 50658 ký 20 micro giây 24323 xác thực 41 micro giây, xác thực nhiều lõi cận trên cho cổng API HS256 10 lõi 607868 mỗi giây RS256 10 lõi 113651 mỗi giây verify là việc làm mỗi request, B hai cấu hình sai v5 từ chối khi ghim thuật toán 1 alg none payload sửa thành admin không chữ ký Parse cộng WithValidMethods HS256 RS256 từ chối signing method none is invalid, 2 token ký HS256 bằng chính khoá công khai RSA dạng PEM làm khoá HMAC server ghim RS256 WithValidMethods RS256 từ chối signing method HS256 is invalid server cho cả HS256 và trả public PEM làm khoá HMAC chấp nhận nguy hiểm bài học một dòng luôn WithValidMethods với đúng một họ thuật toán đừng để callback chọn khoá theo t.Method, C thu hồi bằng danh sách ngắn hạn JWT tự chứa server không giữ trạng thái không xoá được token đã cấp chỉ chặn tới khi nó hết hạn thu hồi jti a1b2c3d4e5f6 token còn sống 15 phút xác thực lại chữ ký hợp lệ true nhưng jti trong deny-list true chặn deny-list TTL bỏ jti khi qua exp kích thước nhỏ hơn hoặc bằng số thu hồi mỗi phút nhân 15 phút không phải mọi token từng cấp đánh đổi access token ngắn 15 phút cộng refresh token dài thu hồi bằng từ chối refresh đợi access hết hạn, nói thẳng chọn HS256 nếu một dịch vụ vừa cấp vừa verify RS256 EdDSA khi nhiều bên cần verify mà không được ký hai lỗi kinh điển đều bị v5 chặn miễn là ghim thuật toán lỗ hổng thật nằm ở code chọn khoá không ở thư viện

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ề

  1. 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.
  2. alg=none và nhầm loại khoá đều bị v5 chặn khi ghim thuật toán bằng WithValidMethods. Token giả chỉ lọt khi code cho nhiều thuật toán và chọn khoá theo t.Method; lỗ hổng ở ứng dụng, không ở thư viện.
  3. Thu hồi bằng deny-list theo jti vớ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

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.