JWT (JSON Web Token) có mặt ở gần như mọi API hiện đại: đăng nhập xong, server cấp một chuỗi dài eyJhbGci... và client đính kèm nó vào mỗi request. Nhìn chuỗi rối mắt đó, nhiều lập trình viên kết luận sai rằng dữ liệu bên trong "đã được mã hoá, an toàn để giấu bí mật". Đây là hiểu lầm nguy hiểm nhất về JWT. Bài này (phần 13 loạt Mật mã) tự tay dựng một JWT bằng python hmac, rồi đo output thật để thấy nó thực sự là gì: một cấu trúc được ký để chống sửa, chứ hoàn toàn không mã hoá để giấu.
Cấu trúc: ba khối Base64URL ngăn bằng dấu chấm
Một JWT gồm ba phần nối bằng dấu chấm: header.payload.signature.
- Header: JSON nhỏ khai thuật toán ký, ví dụ
{"alg":"HS256","typ":"JWT"}. - Payload: JSON chứa dữ liệu (gọi là claim) —
sub(chủ thể),role,exp(hạn dùng)... - Signature: chữ ký
HMAC-SHA256củaheader.payloadbằng khoá bí mật chỉ server biết.
Hai phần đầu chỉ được mã hoá Base64URL (một cách biểu diễn, không dùng khoá), rồi phần thứ ba mới là chữ ký mật mã. Đây là điểm mấu chốt cần khắc sâu ngay.
signing_input = b64url(header) + "." + b64url(payload)
signature = HMAC_SHA256(signing_input, SECRET) # khoá chỉ server biết
token = signing_input + "." + b64url(signature)

Hình 1: JWT là ba khối Base64URL nối bằng dấu chấm; chữ ký = HMAC-SHA256(header.payload, khoá bí mật). Payload chỉ mã hoá Base64URL nên ai cũng đọc được — và alg=none là bẫy phải từ chối.
Đo thật: payload đọc được ngay, không cần khoá
Mình tạo một JWT thật với payload {"sub":"an@coffeecode.vn","role":"user","exp":...} rồi giải Base64URL từng phần — không cần khoá bí mật nào:

Hình 2: Chạy thật — giải Base64URL cho ra header và payload dạng chữ (role":"user"), không cần khoá. Token gốc xác minh True; đổi role thành admin → False "chữ ký KHÔNG khớp"; tấn công alg=none → False "TỪ CHỐI".
HEADER → {"alg":"HS256","typ":"JWT"} và PAYLOAD → {"sub":"an@coffeecode.vn","role":"user",...} hiện ra nguyên văn. Bài học đầu tiên, đo thật: bất kỳ ai chặn được JWT đều đọc được toàn bộ payload. Vì vậy tuyệt đối không đặt mật khẩu, khoá API hay bí mật vào claim. JWT chống sửa, không chống đọc.
Chữ ký bắt mọi thay đổi: thử đổi role thành admin
Nếu payload đọc được, kẻ tấn công đổi "role":"user" thành "role":"admin" thì sao? Mình thử đúng vậy — sửa payload nhưng giữ nguyên chữ ký cũ:
payload mới giải ra: {"sub":"an@coffeecode.vn","role":"admin",...}
Xác minh token BỊ SỬA: False → chữ ký KHÔNG khớp
Server xác minh bằng cách tính lại HMAC-SHA256(header.payload_mới, SECRET) rồi so với chữ ký gửi kèm. Vì payload đã đổi, giá trị HMAC mới hoàn toàn khác chữ ký cũ — không khớp, token bị từ chối. Kẻ tấn công không thể tạo chữ ký hợp lệ cho payload mới vì không có khoá bí mật. Đây chính là giá trị của "ký": phát hiện mọi thay đổi dù chỉ một bit.
Lưu ý kỹ thuật: việc so chữ ký phải dùng so sánh hằng thời gian (hmac.compare_digest) chứ không phải == thường, để tránh rò rỉ thông tin qua thời gian so sánh — đúng bài toán constant-time của loạt này.
alg=none: cái bẫy kinh điển
Có một đòn tấn công lịch sử: kẻ xấu đổi header thành {"alg":"none"} và bỏ trống chữ ký, hy vọng thư viện "tin lời" header mà bỏ qua kiểm tra. Nếu thư viện ẩu chấp nhận, mọi token giả đều qua. Mình thử với server có phòng thủ:
token: eyJhbGciOiAibm9uZSIs... .payloadAdmin. (chữ ký rỗng)
Xác minh alg=none: False → TỪ CHỐI alg=none
Server chốt trước thuật toán mình chấp nhận (chỉ HS256), nên khi thấy alg=none nó từ chối thẳng, không cho hạ cấp xuống "không ký". Đây là lý do đừng bao giờ để thư viện tự đọc alg từ header token rồi tin theo — luôn khai cứng thuật toán ở phía xác minh.
Đánh đổi cần cân nhắc
JWT khó thu hồi trước hạn — đó là cái giá của "không trạng thái". Ưu điểm lớn của JWT là server không cần tra CSDL mỗi request (chữ ký tự chứng minh). Nhưng mặt trái: một khi đã cấp, token hợp lệ tới khi exp — không có cách "đăng xuất tức thì" trừ khi dựng thêm danh sách đen (blacklist), mà làm vậy là quay lại có trạng thái. Giải pháp thực dụng: exp ngắn + refresh token.
HS256 (khoá đối xứng) và RS256 (khoá bất đối xứng) cho tình huống khác nhau. HS256 dùng một khoá bí mật để cả ký lẫn xác minh — gọn khi cùng một bên làm cả hai. Nhưng nếu nhiều dịch vụ cần xác minh mà không được quyền ký, hãy dùng RS256: server ký bằng khoá riêng, các bên khác xác minh bằng khoá công khai (chủ đề chữ ký số của loạt này).
JWT không phải lựa chọn mặc định cho mọi phiên. Với web truyền thống một server, session cookie phía server thường đơn giản và an toàn hơn (thu hồi được ngay, không lộ dữ liệu ở client). JWT toả sáng ở API không trạng thái, nhiều dịch vụ, hoặc xác thực giữa các bên — đừng dùng chỉ vì nó "thời thượng".
Ba ý mang về
- JWT được KÝ chứ không MÃ HOÁ: đo thật, giải Base64URL cho ra header và payload dạng chữ mà không cần khoá — nên đừng bao giờ để bí mật trong payload. Nó bảo vệ tính toàn vẹn, không phải tính bí mật.
- Chữ ký HMAC bắt mọi thay đổi: đo thật, đổi
rolethànhadminrồi giữ chữ ký cũ → xác minhFalse"chữ ký không khớp", vì server tính lại HMAC bằng khoá bí mật mà kẻ tấn công không có (và so bằng hàm hằng-thời-gian). - Phải chốt thuật toán, từ chối
alg=none: đo thật tokenalg=nonebị từ chối — luôn khai cứng thuật toán xác minh thay vì tinalgtrong header; và nhớ JWT khó thu hồi trướcexp.
Nguồn
- RFC 7519 — JSON Web Token (JWT): https://datatracker.ietf.org/doc/html/rfc7519
- RFC 7515 — JSON Web Signature (JWS): https://datatracker.ietf.org/doc/html/rfc7515
- Auth0 — JWT Handbook / Introduction to JSON Web Tokens: https://jwt.io/introduction
Phần sau ta xem một thuật toán mã hoá dòng hiện đại: ChaCha20-Poly1305 — vì sao nó nhanh trên thiết bị không có tăng tốc AES phần cứng, và cách nó vừa mã hoá vừa xác thực (AEAD) trong một bước.