Loạt bài phòng thủ: hiểu lỗ hổng để viết code an toàn, demo trong lab cô lập.
JWT (JSON Web Token) phổ biến vì nó giải một bài toán gọn gàng: làm sao server tin một token do client gửi mà không cần tra database mỗi lần? Câu trả lời là chữ ký mật mã. Server ký token khi phát hành; mỗi lần nhận lại, nó verify chữ ký — nếu khớp, nội dung token (ai, quyền gì, hết hạn khi nào) là đáng tin, vì không ai sửa được mà không có khoá bí mật.
Nhưng "chữ ký bảo vệ" chỉ đúng khi bạn verify cho đúng. Và JWT có một bẫy nổi tiếng đến mức thành bài học kinh điển: nếu verifier tin trường alg ghi trong chính token (thay vì ép thuật toán nó mong đợi), kẻ tấn công đặt alg: "none" và bỏ chữ ký — một verifier ngây thơ sẽ "ồ, token này không cần chữ ký" và chấp nhận token giả. Bài này (phần 6 loạt Bảo mật web) tự cài JWT để thấy rõ chữ ký bảo vệ ra sao, và bẫy alg=none nguy hiểm thế nào.
Cơ chế: chữ ký, và vì sao đừng tin "alg" trong token

Hình 1: JWT = header.payload.signature (base64url). Chữ ký HS256 = HMAC-SHA256 của header.payload với secret — header/payload chỉ mã hoá base64 (ai cũng đọc được) nhưng không ai sửa được mà không có secret. Bẫy alg=none: kẻ tấn công đổi alg thành none, bỏ chữ ký; verifier ngây thơ tin header → bỏ qua chữ ký → chấp nhận token giả. Verify đúng: ép alg mong đợi, verify chữ ký constant-time.
Tái hiện thật trong go-lab
Mình tự cài JWT HS256 tối giản trong go-lab (golang 1.23, chỉ dùng crypto/hmac + base64 + json — để kiểm soát hoàn toàn và minh hoạ bẫy), rồi thử ba kịch bản.

Hình 2: Kết quả thật — ① JWT hợp lệ: verify role=user, không lỗi; ② giả mạo payload role=admin giữ chữ ký cũ: verifyStrict → "chữ ký không khớp" (phát hiện); ③ bẫy alg=none (cùng token, hai verifier): verifyNaive (tin alg header) → role=admin chấp nhận (nguy hiểm), verifyStrict (ép HS256) → "alg không được chấp nhận: none" (chặn).
Ba kịch bản làm rõ cả sức mạnh lẫn cạm bẫy:
- Chữ ký bảo vệ payload khỏi bị sửa. Token hợp lệ verify ra role=user. Khi kẻ tấn công sửa payload thành
role: "admin"nhưng giữ chữ ký cũ (vì không có secret để ký lại), verify phát hiện ngay: chữ ký cũ được tính trên payload cũ, không khớp payload mới → "chữ ký không khớp" → từ chối. Đây là điều JWT làm tốt: không ai sửa được nội dung mà không có khoá. - Nhưng bẫy alg=none biến verifier thành kẻ phản bội. Kẻ tấn công đổi header thành
{"alg":"none"}và bỏ hẳn chữ ký. Với verifier ngây thơ — đọcalgtừ header rồi làm theo — nó thấy "none" nghĩa là "không cần chữ ký" và chấp nhận token! Kết quả: role=admin, không lỗi — giả mạo thành công, kẻ tấn công leo thang quyền chỉ bằng cách sửa text. Lỗ hổng không nằm ở JWT mà ở cách verify. - Verify đúng ép thuật toán mong đợi. Verifier nghiêm ngặt không tin
algtrong token — nó biết trước mình dùng HS256 và ép: nếualgkhác "HS256" → từ chối ngay ("alg không được chấp nhận: none"). Cùng token alg=none đó bị chặn. Bài học cốt lõi: chữ ký chỉ bảo vệ khi bạn kiểm soát thuật toán verify, đừng để token tự khai nó cần verify kiểu gì.
Đánh đổi cần cân nhắc
Luôn verify chữ ký + ép alg, và dùng thư viện đã kiểm chứng. Hai nguyên tắc bắt buộc: (1) luôn verify chữ ký (đừng chỉ base64-decode payload rồi tin), và (2) ép thuật toán mong đợi, từ chối "none" và từ chối thuật toán khác dự kiến. Một bẫy liên quan của RS256/HS256 lẫn lộn: nếu server dùng RS256 (verify bằng public key) mà verifier chấp nhận alg tuỳ token, kẻ tấn công có thể gửi token HS256 ký bằng chính public key (vốn công khai) — verifier dùng public key làm secret HMAC và chấp nhận. Vì các bẫy tinh vi này, dùng thư viện JWT uy tín (và cấu hình nó ép alg) thay vì tự cài cho production — mình tự cài ở đây chỉ để minh hoạ cơ chế.
JWT KHÔNG mã hoá — đừng để bí mật trong payload. Một hiểu lầm phổ biến: nghĩ JWT "mã hoá". Không. Header và payload chỉ mã hoá base64url — bất kỳ ai có token đều đọc được nội dung (dán vào jwt.io là thấy). Chữ ký chỉ chống sửa đổi, không chống đọc. Vì vậy không bao giờ để thông tin nhạy cảm (mật khẩu, số thẻ, khoá) trong payload JWT. Nếu cần giấu nội dung, dùng JWE (encrypted) — nhưng với token xác thực thông thường, chỉ cần nhớ: payload là công khai.
JWT khó thu hồi trước hạn — thiết kế thời hạn ngắn + refresh. Nhược điểm cố hữu của JWT so với session trong DB: vì server không tra DB để verify, nó cũng không dễ huỷ một token trước khi hết hạn (ví dụ khi người dùng đăng xuất hay bị khoá). Token hợp lệ tới exp dù bạn muốn chặn ngay. Hai cách chữa: (1) đặt thời hạn ngắn cho access token (vài phút) + dùng refresh token (lưu DB, thu hồi được) để lấy access token mới; (2) duy trì blocklist các token bị thu hồi (nhưng điều này phần nào triệt tiêu lợi thế "không tra DB"). Và luôn kiểm exp, iss, aud khi verify — nhiều lỗ hổng đến từ việc quên kiểm token đã hết hạn hay đúng đối tượng.
Ba ý mang về
- Chữ ký bảo vệ payload khỏi sửa đổi: tái hiện thật, token hợp lệ verify role=user; sửa payload thành role=admin mà không có secret để ký lại → verify phát hiện "chữ ký không khớp" (chữ ký cũ tính trên payload cũ) → từ chối. Không ai sửa nội dung mà không có khoá.
- Bẫy alg=none: đừng tin alg trong token: tái hiện thật, kẻ tấn công đặt alg=none và bỏ chữ ký — verifier ngây thơ (tin header) chấp nhận role=admin (giả mạo thành công), verifier đúng (ép HS256) từ chối; chữ ký chỉ bảo vệ khi bạn kiểm soát thuật toán verify.
- Verify đúng + hiểu giới hạn JWT: luôn verify chữ ký, ép alg mong đợi, dùng thư viện uy tín (cẩn thận RS256/HS256 lẫn lộn); JWT không mã hoá payload (đừng để bí mật trong đó); khó thu hồi trước hạn (thời hạn ngắn + refresh token + kiểm exp/iss/aud).
Nguồn
- OWASP — JSON Web Token for Java/Security Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html
- IETF RFC 7519 — JSON Web Token (JWT): https://www.rfc-editor.org/rfc/rfc7519
- Auth0 — Critical vulnerabilities in JWT libraries (alg=none): https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/
Phần sau ta bàn hai lỗ hổng "chèn" khác: command injection (nối chuỗi vào lệnh shell) và path traversal (dùng ../ để thoát khỏi thư mục cho phép) — tái hiện và cách chặn bằng tách tham số và làm sạch đường dẫn.