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-SHA256 của header.payload bằ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)

Ảnh chụp đoạn mã nền tối minh hoạ JWT ba phần header payload signature ký chứ không mã hoá, cấu trúc ba khối Base64URL ngăn bằng dấu chấm header payload signature header alg HS256 typ JWT thuật toán ký payload sub role exp dữ liệu gọi là claim signature HMAC-SHA256 của header chấm payload với khoá bí mật, ký và xác minh mã rút gọn signing_input bằng b64url header cộng chấm cộng b64url payload signature bằng HMAC_SHA256 signing_input SECRET xác minh server tính lại signature từ hai phần đầu cộng SECRET rồi so bằng hmac compare_digest so hằng thời gian, hai hiểu lầm chết người một payload không mã hoá chỉ Base64URL ai cũng giải ra đọc đừng để mật khẩu bí mật trong payload hai alg none token bỏ chữ ký thư viện ẩu chấp nhận giả mạo server phải chốt thuật toán từ chối alg none

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:

Ảnh chụp bảng kết quả chạy thật python hmac cộng hashlib output thật, một JWT thật giải Base64URL ra đọc được ngay không mã hoá chuỗi eyJhbGci chấm eyJzdWIi chấm tlPtPeyX HEADER giải ra alg HS256 typ JWT PAYLOAD giải ra sub an at coffeecode.vn role user exp 1893456000 chữ ký tlPtPeyXgI3cf1zDCwuK2fam không giải ngược được, hai xác minh token gốc True role user hợp lệ, ba kẻ tấn công đổi role thành admin giữ chữ ký cũ payload mới giải ra role admin xác minh token bị sửa False chữ ký không khớp đổi một bit payload HMAC không còn khớp bị từ chối, bốn tấn công alg none bỏ chữ ký token eyJhbGciOiAibm9uZSIs payloadAdmin chữ ký rỗng xác minh alg none False từ chối alg none server chốt thuật toán không cho hạ cấp xuống không ký

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ề

  1. 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.
  2. Chữ ký HMAC bắt mọi thay đổi: đo thật, đổi role thành admin rồi giữ chữ ký cũ → xác minh False "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).
  3. Phải chốt thuật toán, từ chối alg=none: đo thật token alg=none bị từ chối — luôn khai cứng thuật toán xác minh thay vì tin alg trong header; và nhớ JWT khó thu hồi trước exp.

Nguồn

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.