# 🎫 JWT: cấu trúc, cách kiểm tra và các lỗi hay gặp

By Better Wise

> Sign in to view the full content of this lesson.

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:

1. Cắt token: `const [h, p] = token.split('.')`
2. Giải mã header: `JSON.parse(Buffer.from(h, 'base64url').toString())`
3. Làm tương tự với phần payload
4. Đối chiếu: `exp` là giây kể từ epoch, đổi ra giờ bằng `new 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`

**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:

```mermaid
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`:

```js
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ý.
- `issuer` và `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?

```js
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:

1. **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.
2. **Tin `alg` trong header.** Luôn pin `algorithms` ở phía server.
3. **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ợ.
4. **Lưu token sai chỗ.** `localStorage` thì 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à header `Referer`. Với web, access token nên nằm trong bộ nhớ hoặc trong cookie `HttpOnly; Secure; SameSite`.
5. **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.
6. **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ì?

```json
{
  "sub": "u_42",
  "email": "an@example.com",
  "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ếu `algorithms` là 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ằng `exp` dà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ửi `alg: none` là qua cửa.
- Dùng chung một secret cho mọi môi trường, dev lộ là production lộ.
- Nhét `role` và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 `exp` bằ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.

---

## Contents

Part of [🔐 Xác thực và phân quyền: session, JWT và OAuth](https://everwise.study/courses/xac-thuc-va-phan-quyen-session-jwt-va-oauth.md)

- **🍪 Nền tảng xác thực: session, cookie và mật khẩu**
  - [🍪 Session và cookie: chọn khi nào](https://everwise.study/courses/xac-thuc-va-phan-quyen-session-jwt-va-oauth/session-va-cookie-chon-khi-nao.md)
  - [🎫 JWT: cấu trúc, cách kiểm tra và các lỗi hay gặp](https://everwise.study/courses/xac-thuc-va-phan-quyen-session-jwt-va-oauth/jwt-cau-truc-cach-kiem-tra-va-loi-hay-gap.md) (sign-in required) — this lesson
  - [🔑 Hash mật khẩu: bcrypt, argon2 và lỗi thường gặp](https://everwise.study/courses/xac-thuc-va-phan-quyen-session-jwt-va-oauth/hash-mat-khau-bcrypt-argon2-va-loi-thuong-gap.md)
  - [✅ Checkpoint: nền tảng xác thực](https://everwise.study/courses/xac-thuc-va-phan-quyen-session-jwt-va-oauth/checkpoint-nen-tang-xac-thuc.md)
- **♻️ Refresh token và phân quyền theo vai trò**
  - [🔄 Refresh token: luồng cấp mới, rotation và thu hồi](https://everwise.study/courses/xac-thuc-va-phan-quyen-session-jwt-va-oauth/refresh-token-luong-cap-moi-rotation-va-thu-hoi.md)
  - [🛡️ Phân quyền theo vai trò (RBAC) trong API](https://everwise.study/courses/xac-thuc-va-phan-quyen-session-jwt-va-oauth/phan-quyen-theo-vai-tro-rbac-trong-api.md)
  - [✅ Checkpoint: refresh token và phân quyền](https://everwise.study/courses/xac-thuc-va-phan-quyen-session-jwt-va-oauth/checkpoint-refresh-token-va-phan-quyen.md)
- **🌐 Đăng nhập Google, AI viết auth và cách kiểm tra**
  - [🔵 Đăng nhập bằng Google: OAuth 2.0 ở mức thực dụng](https://everwise.study/courses/xac-thuc-va-phan-quyen-session-jwt-va-oauth/dang-nhap-bang-google-oauth-2-thuc-dung.md)
  - [🤖 Khi để AI viết luồng auth: sai lầm và checklist kiểm tra](https://everwise.study/courses/xac-thuc-va-phan-quyen-session-jwt-va-oauth/ai-viet-luong-auth-sai-lam-va-checklist-kiem-tra.md)
  - [🧩 Mini project: ghép luồng auth hoàn chỉnh cho sản phẩm](https://everwise.study/courses/xac-thuc-va-phan-quyen-session-jwt-va-oauth/mini-project-ghep-luong-auth-hoan-chinh.md) (premium content)
  - [✅ Checkpoint: đăng nhập Google và kiểm tra luồng auth](https://everwise.study/courses/xac-thuc-va-phan-quyen-session-jwt-va-oauth/checkpoint-dang-nhap-google-va-kiem-tra-luong-auth.md) (premium content)
- [Course cheatsheet](https://everwise.study/courses/xac-thuc-va-phan-quyen-session-jwt-va-oauth/cheatsheet.md) (premium content)
