JWT: cấu trúc, cách kiểm tra và các lỗi hay gặp
Bài trước bạn đã chọn giữa session và cookie: server giữ trạng thái, client chỉ cầm một session id. JWT đi hướng ngược lại, server không nhớ gì cả, mọi thứ cần biết nằm trong chính token. Vì vậy câu hỏi tôi luôn hỏi trước khi viết dòng code auth đầu tiên là: token này ai giữ, sống bao lâu, bị lộ thì sao. Bài này trang bị cho bạn đủ để tự trả lời ba câu đó với JWT.
Sau bài này, bạn sẽ:
- Đọc được header và payload của bất kỳ JWT nào bằng tay.
- Viết được đoạn verify đúng: chữ ký,
exp,iss,aud,algorithms. - Nhận diện 5 lỗi hay gặp khiến luồng JWT bị lộ hoặc bị bypass.
- Biết khi nào JWT là lựa chọn sai cho project của mình.
Ba phần của một JWT
Một JWT là ba chuỗi base64url nối với nhau bằng dấu chấm: header.payload.signature.
| Phần | Chứa gì | Ai đọc được |
|---|---|---|
| Header | alg (thuật toán), typ, đôi khi kid |
Ai cũng đọc được |
| Payload | các claim: sub, role, exp, iat, iss, aud |
Ai cũng đọc được |
| Signature | chữ ký trên header.payload |
Không đọc được nội dung, chỉ kiểm tra được |
Chữ ký với HS256 được tính bằng HMAC-SHA256(base64url(header) + "." + base64url(payload), secret). Server giữ secret, client chỉ giữ token. Nếu ai đó sửa payload, ví dụ đổi "role":"user" thành "role":"admin", chữ ký không còn khớp và verify() sẽ ném lỗi.
Điểm phải khắc vào đầu: Payload chỉ được encode base64url, không được mã hóa. Bất kỳ ai cầm token đều đọc được toàn bộ nội dung. Phần thứ ba của JWT là _____, dùng để chứng minh token không bị sửa, chứ không giấu nội dung.
Tự tay giải mã một token
Làm ngay, đừng đọc suông. Mở terminal Node và chạy:
- Cắt token:
const [h, p] = token.split('.') - Giải mã header:
JSON.parse(Buffer.from(h, 'base64url').toString()) - Làm tương tự với phần payload
- Đối chiếu:
explà giây kể từ epoch, đổi ra giờ bằngnew Date(exp * 1000)
Bài tập 1. Giải mã header sau và cho biết token dùng thuật toán gì:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
Sign in to view the full content of this lesson. Sign in
Xem đáp án
Kết quả là {"alg":"HS256","typ":"JWT"}. Token dùng HMAC-SHA256, tức là ký đối xứng bằng một secret chung. Nếu server của bạn ký bằng HS256 thì secret đó phải nằm tuyệt đối ở phía server.
Bài tập 2. Một payload có {"sub":"u_42","role":"user","exp":1735689600}. Token còn hạn không, biết hôm nay là 10/06/2025?
Xem gợi ý
1735689600 là 00:00 UTC ngày 01/01/2025, đã hết hạn từ lâu. Nếu server trả 200 cho token này thì gần như chắc chắn nó đang dùng decode() thay vì verify().
Xác minh chữ ký và hạn: đúng thứ tự
Nguyên tắc: kiểm tra chữ ký trước, chỉ tin payload sau khi chữ ký và các claim hợp lệ. Đây là thứ tự tôi luôn vẽ ra giấy:
flowchart TD
A["📥 Nhận token từ request"] --> B["✂️ Tách 3 phần bằng dấu chấm"]
B --> C{"Chữ ký hợp lệ?"}
C -- Không --> X["🚫 401 và dừng"]
C -- Có --> D{"exp còn hạn? iss, aud đúng?"}
D -- Không --> X
D -- Có --> E["✅ Gắn user vào request"]
Code đúng, dùng jsonwebtoken:
const jwt = require('jsonwebtoken')
// ĐÚNG: verify kiểm tra chữ ký + hạn + issuer + audience
const payload = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'], // pin thuật toán, đừng để client chọn
issuer: 'api.cuahang.vn',
audience: 'web.cuahang.vn',
clockTolerance: 5, // lệch giờ giữa các máy, tính bằng giây
})
Ba tham số quan trọng nhất:
algorithms: chốt danh sách thuật toán server chấp nhận. Không có nó, kẻ tấn công có thể gửi header{"alg":"none"}hoặc đổi sang thuật toán khác để lách chữ ký.issuervàaudience: token phát cho mobile không được dùng để gọi API web, token của service A không được dùng ở service B.clockTolerance: nhỏ thôi, vài giây. Đặt 300 giây là mở cửa sổ cho token hết hạn thêm 5 phút.
Claim _____ là thời điểm token hết hạn, tính bằng giây kể từ epoch. Trước khi tin payload, server phải gọi hàm _____ chứ không phải jwt.decode().
Bài tập 3. Đoạn code sau sai ở đâu và sửa thế nào?
const payload = jwt.decode(token)
if (payload.role === 'admin') return grantAdmin()
Xem đáp án
jwt.decode() chỉ parse base64url, không kiểm tra ch Trading tực gì. Ai cũng có thể tự tạo một token với "role":"admin" và được cấp quyền. Sửa: dùng jwt.verify(token, secret, { algorithms: ['HS256'] }) bọc trong try/catch, và chỉ đọc payload.role sau khi verify thành công.
Những lỗi hay gặp khiến auth bị lộ
Đây là danh sách tôi gặp lại nhiều nhất khi review code auth, kể cả code do AI viết:
- Dùng
decode()thay vìverify(). Lỗi nặng nhất và cũng phổ biến nhất. Không verify thì token chỉ là tờ giấy ai cũng viết được. - Tin
algtrong header. Luôn pinalgorithmsở phía server. - Nhét dữ liệu nhạy cảm vào payload. Mật khẩu, số CCCD, số thẻ, secret nội bộ, dữ liệu của người khác. Payload ai cũng đọc được, và token hay bị dán vào log, vào Slack, vào ticket hỗ trợ.
- Lưu token sai chỗ.
localStoragethì mọi đoạn JS lạ đọc được, dính XSS là mất token. Gắn token vào query string thì nó rò qua access log và headerReferer. Với web, access token nên nằm trong bộ nhớ hoặc trong cookieHttpOnly; Secure; SameSite. - Token sống quá lâu và không thu hồi được. JWT là stateless, đã phát thì không rút lại được trước
exp. Access token 7 ngày là món quà cho kẻ tấn công. Access token nên sống ngắn, khoảng _____; phần "sống lâu" để refresh token lo, bài sau sẽ nói. - Secret yếu hoặc dùng chung.
secret = "secret"thì ký gì cũng vô nghĩa. Secret phải ngẫu nhiên, đủ dài, nằm trong biến môi trường, không commit vào repo.
Bài tập 4. Một đồng nghiệp gửi bạn payload sau và hỏi "ổn chưa?". Bạn trả lời gì?
{
"sub": "u_42",
"email": "[email protected]",
"password_hash": "$2b$10$...",
"role": "admin",
"exp": 1893456000
}
Xem gợi ý
Ba vấn đề: (1) password_hash tuyệt đối không được nằm trong payload, ai có token là có hash để mang đi crack offline; (2) email là dữ liệu cá nhân, chỉ đưa vào nếu thật sự cần, tốt nhất giữ sub rồi tra DB khi cần; (3) exp là năm 2030, quá dài, cộng với role nằm trong token nghĩa là user bị hạ quyền vẫn giữ admin tới 2030. Sửa: payload chỉ nên có sub, role (nếu chấp nhận staleness), iat, exp ngắn, iss, aud.
Quy tắc nhanh khi làm việc với JWT
- Trước khi merge bất kỳ PR nào có chữ
jwt, grep hai thứ:decode(vàalgorithms. Thiếualgorithmslà phải sửa. - Tự hỏi ba câu với mọi token bạn tạo ra: ai giữ nó, nó sống bao lâu, bị lộ thì thiệt hại tới đâu. Không trả lời được thì đừng phát hành.
- Đừng dùng JWT như session store. Nếu bạn cần đăng xuất tức thì, đổi quyền tức thì, hoặc đếm thiết bị đang đăng nhập, session trong DB hoặc refresh token có rotation sẽ trung thực hơn.
- Khi cần xoay secret, dùng header
kidđể server biết token đang được ký bằng key nào, rồi giữ key cũ thêm một khoảng bằngexpdài nhất. - Viết test cho các ca xấu: token hết hạn, token bị sửa một ký tự, token ký bằng secret khác, token
alg:none. Nếu test không đỏ khi bạn cố tình phá, luồng verify đang không chạy.
Chốt lại và bước tiếp theo
Bốn điều mang theo: JWT gồm header, payload, signature; payload chỉ encode chứ không mã hóa, nên đừng đặt gì nhạy cảm vào đó; luôn verify() với algorithms, issuer, audience, exp; và token sống ngắn, lưu đúng chỗ, có kế hoạch cho lúc bị lộ.
Bài sau chuyển sang hash mật khẩu: bcrypt và argon2 khác nhau ở đâu, tham số cost nên đặt bao nhiêu, và những lỗi khiến hash trở nên vô dụng.
Front: JWT gồm những phần nào? Back: Ba phần: header, payload, signature, nối bằng dấu chấm.
Front: Payload của JWT có được mã hóa không? Back: Không. Chỉ base64url encode, ai cầm token cũng đọc được.
Front: Signature trong JWT dùng để làm gì? Back: Chứng minh token do server phát hành và không bị sửa.
Front: Khác nhau giữa jwt.decode() và jwt.verify()? Back: decode chỉ đọc nội dung; verify kiểm tra chữ ký và các claim.
Front: Claim exp nghĩa là gì? Back: Thời điểm hết hạn, tính bằng giây kể từ epoch.
Front: Vì sao phải pin tham số algorithms khi verify? Back: Chặn tấn công alg:none và việc client tự đổi thuật toán trong header.
Front: Vì sao không được đặt mật khẩu hay password_hash trong payload? Back: Payload ai cũng đọc được, hash bị lộ là mang đi crack offline được.
Front: Lưu access token trong localStorage có rủi ro gì? Back: Mọi JS trên trang đọc được, dính XSS là mất token.
Front: JWT có thu hồi ngay được không? Back: Không, vì stateless. Phải chờ exp hoặc dùng thêm cơ chế thu hồi/refresh token.
Front: Access token nên sống bao lâu? Back: Ngắn, khoảng 5 đến 15 phút.
Front: Header của JWT thường chứa gì? Back: alg (thuật toán) và typ, đôi khi có kid để chọn key.
Front: Chuyện gì xảy ra nếu sửa payload mà không có secret? Back: Chữ ký không khớp, verify thất bại.
Front: iss và aud dùng để chặn điều gì? Back: Dùng chéo token giữa service hoặc giữa web và mobile.
Bốn câu hỏi kiểm tra trước khi phát token ra ngoài
Trước khi res.json({ token }), tôi luôn tự hỏi bốn câu. Nếu câu nào chưa trả lời được, token đó chưa nên rời server.
| Câu hỏi | Trả lời đúng trông như thế nào |
|---|---|
| Token này ai giữ? | Client biết rõ: memory, cookie HttpOnly, hay storage. Không đoán mò. |
| Sống bao lâu? | Access token 5 đến 15 phút; exp được set tường minh, không để mặc định. |
| Bị lộ thì sao? | Có đường thu hồi (refresh token, blacklist, hoặc rotate secret) và biết mất gì. |
| Ai kiểm tra nó? | Mọi service đều verify với thuật toán pin cứng, không decode suông. |
Vài lỗi tôi gặp lặp lại nhiều lần nhất:
- Verify mà quên
algorithms: ['HS256']. Client gửialg: nonelà qua cửa. - Dùng chung một secret cho mọi môi trường, dev lộ là production lộ.
- Nhét
rolevào payload rồi không bao giờ kiểm lại. User bị hạ quyền vẫn giữ role cũ tới lúc token hết hạn. - So sánh
expbằng tay với giờ máy local thay vì để thư viện kiểm tra, dính lệch múi giờ.
Quy tắc của tôi: payload chỉ chứa thứ vô hại khi bị đọc, ví dụ sub, role, exp, iss. Bất cứ thứ gì bạn không muốn in ra log thì đừng đặt vào đó.
Chốt lại và bước tiếp theo
Ba điều cần nhớ: JWT gồm header, payload, signature; payload chỉ encode chứ không mã hóa; và mọi quyết định bảo mật nằm ở bước verify, không phải ở bước decode. Hãy coi token như một tờ giấy chứng nhận có đóng dấu: ai cũng đọc được nội dung, nhưng chỉ server mới xác nhận được con dấu thật.
Bài sau chúng ta sang phần hash mật khẩu với bcrypt và argon2, nơi cùng một câu hỏi được đặt ra: mật khẩu này ai giữ, sống bao lâu, bị lộ thì sao.