🧠 Tổng quan — JWT là gì?
JWT (JSON Web Token) là một chuẩn mở (RFC 7519) dùng để truyền thông tin an toàn giữa các bên dưới dạng một JSON object được ký số (digitally signed).
Hãy hình dung JWT như một thẻ nhân viên điện tử: thẻ chứa thông tin của bạn (tên, phòng ban, quyền hạn), được công ty ký xác nhận. Bảo vệ ở cửa không cần gọi HR để xác minh — họ chỉ cần kiểm tra chữ ký trên thẻ.
🏗️ Cấu trúc JWT
Một JWT gồm 3 phần, phân tách bằng dấu chấm (.):
xxxxx.yyyyy.zzzzz
│ │ │
Header Payload Signature
1. Header
Chứa loại token và thuật toán ký:
{
"alg": "RS256",
"typ": "JWT"
}
2. Payload (Claims)
-
Bổ xung
JWT Payload được định nghĩa trong RFC 7519 — chia claims thành 3 nhóm rõ ràng:
1. 🔴 Không có claim nào thực sự "bắt buộc" theo RFC
RFC 7519 không bắt buộc claim nào cả về mặt kỹ thuật — một JWT payload rỗng
{}vẫn hợp lệ về cú pháp. Tuy nhiên trong thực tế, một số claim là bắt buộc về mặt bảo mật.
2. Phân loại claims
🔴 Bắt buộc trên thực tế (Strongly Recommended → Treat as Required)
Claim Giá trị Tại sao bắt buộc subUser ID (string) Định danh ai đang dùng token. Nếu không có sub, server không biết token này thuộc về user nào.tymon/jwt-authtự lấy$user->getJWTIdentifier()để set claim này.expUnix timestamp Giới hạn thời gian sống của token. Không có exp→ token tồn tại mãi mãi → nếu bị lộ, attacker dùng được vĩnh viễn. Đây là lỗ hổng bảo mật nghiêm trọng.iatUnix timestamp Thời điểm token được tạo. Dùng để tính tuổi token, phát hiện token cũ, và hỗ trợ token rotation. Hầu hết library tự thêm. 🟡 Nên có (Best Practice)
Claim Giá trị ví dụ Tại sao nên có iss"<https://api.myapp.com>"Ai cấp token này? Nếu nhiều service cùng dùng JWT, issgiúp Resource Server từ chối token từ nguồn không tin cậy. Ví dụ: service B không nhận token do service A cấp.aud"mobile-app"hoặc["api", "mobile"]Token này dùng cho ai? Ngăn confused deputy attack — token cấp cho app mobile không nên dùng được cho admin API. jtiUUID v4 JWT ID — định danh duy nhất của token. Dùng để implement blacklist khi logout, hoặc ngăn token bị dùng lại (replay attack). nbfUnix timestamp Not Before — token chưa hợp lệ trước thời điểm này. Hữu ích khi cấp token trước (pre-issue) cho một sự kiện trong tương lai. 🟢 Tuỳ chọn — Custom Claims (Private Claims)
Đây là data bạn tự định nghĩa, được nhúng vào payload để Resource Server không cần query DB:
Claim Ví dụ Tại sao thêm Lưu ý role"admin"Phân quyền không cần DB query Chỉ lưu role tổng, không lưu permission list dài email"user@example.com"Hiển thị nhanh ở frontend Không lưu nếu email có thể thay đổi plan"premium"Kiểm tra gói dịch vụ Nên để expngắn để tránh stale datatenant_id"org_123"Multi-tenant apps Cần thiết khi một user thuộc nhiều tổ chức
3. Những thứ KHÔNG được lưu trong payload
Tuyệt đối không lưu:
passwordhay bất kỳ secret nào- Số CMND, thẻ tín dụng, thông tin nhạy cảm
- Permission list dài (ví dụ: 50 permissions) → token phình to, gửi lên mỗi request
Lý do: Payload chỉ được Base64URL encode, không phải mã hóa. Bất kỳ ai intercept được token đều có thể decode và đọc payload bằng
base64_decode()hoặc trực tiếp trên jwt.io.
4. Cụ thể trong Laravel (
tymon/jwt-auth)JWTAuth::fromUser($user)tự động thêm các claims này:// Payload được tạo tự động: { "sub": "1", // từ $user->getJWTIdentifier() "iat": 1711234567, // thời điểm tạo "exp": 1711238167, // iat + jwt.ttl (config) "nbf": 1711234567, // = iat (mặc định) "jti": "abc123...", // UUID tự generate "iss": "<http://localhost>", // APP_URL "prv": "87e0af1..." // hash của provider class (internal) }Thêm custom claims bằng cách implement
JWTSubjecttrên User model:// app/Models/User.php use Tymon\\JWTAuth\\Contracts\\JWTSubject; class User extends Authenticatable implements JWTSubject { // Bắt buộc — trả về giá trị cho claim 'sub' public function getJWTIdentifier(): mixed { return $this->getKey(); // thường là $this->id } // Custom claims — thêm data vào payload public function getJWTCustomClaims(): array { return [ 'role' => $this->role, // ✅ nên có 'tenant_id' => $this->tenant_id, // ✅ nếu multi-tenant // 'email' => $this->email, // ⚠️ cân nhắc nếu email có thể đổi // 'permissions' => $this->permissions, // ❌ quá nặng nếu list dài ]; } }
5. Tóm tắt nhanh
🔴 Bắt buộc thực tế: sub, exp, iat 🟡 Nên có: iss, aud, jti 🟢 Tuỳ chọn: role, tenant_id, plan, email 🚫 Không được lưu: password, secrets, data nhạy cảm, list quá dàiRule of thumb: Payload chỉ nên chứa thông tin mà mọi request đều cần, đủ để xác định ai và có quyền gì — không hơn không kém.
Chứa thông tin người dùng và metadata. Lưu ý: phần này chỉ được mã hóa Base64, KHÔNG được mã hóa — bất kỳ ai cũng có thể đọc được.
{
"sub": "user_123",
"name": "Trung Phan",
"role": "admin",
"iat": 1711234567,
"exp": 1711235467
}
| Claim | Ý nghĩa |
|---|---|
sub |
Subject — ID người dùng |
iat |
Issued At — thời điểm tạo token |
exp |
Expiration — thời điểm hết hạn |
role |
Custom claim — quyền hạn người dùng |
3. Signature
Được tạo bằng cách ký Header + Payload với secret key hoặc private key:
RS256(base64(header) + "." + base64(payload), privateKey)
Chữ ký này đảm bảo không ai có thể giả mạo hoặc sửa đổi nội dung token mà không bị phát hiện.
⚙️ Cơ chế hoạt động — Flow xác thực
sequenceDiagram
participant Client as 🖥️ Client (Browser/App)
participant Auth as 🔐 Auth Server
participant API as 📦 Resource Server (API)
Client->>Auth: POST /login (username + password)
Auth->>Auth: Xác minh thông tin đăng nhập
alt ✅ Đăng nhập thành công
Auth->>Auth: Tạo JWT (Header + Payload + Ký số)
Auth-->>Client: 200 OK + Access Token (JWT) + Refresh Token
else ❌ Thất bại
Auth-->>Client: 401 Unauthorized
end
Note over Client: Lưu Access Token (memory/cookie)
Client->>API: GET /api/data\nAuthorization: Bearer eyJhbG...
API->>API: 1. Xác minh chữ ký\n2. Kiểm tra exp (hết hạn chưa?)
alt ✅ Token hợp lệ
API-->>Client: 200 OK + Dữ liệu
else ⏰ Token hết hạn
API-->>Client: 401 Token Expired
Client->>Auth: POST /refresh (Refresh Token)
Auth-->>Client: Access Token mới
end
Giải thích từng bước:
- Đăng nhập: Client gửi username/password → Auth Server xác minh → nếu hợp lệ, tạo JWT và trả về.
- Lưu token: Client lưu Access Token (thường trong memory hoặc
httpOnly cookie). - Gọi API: Mỗi request, Client đính kèm token vào header
Authorization: Bearer <token>. - Xác minh: API Server kiểm tra chữ ký và thời hạn — không cần truy vấn database.
- Làm mới: Khi Access Token hết hạn, dùng Refresh Token để lấy token mới mà không cần đăng nhập lại.
✅ Ưu điểm
- Stateless (không trạng thái): Server không cần lưu session → dễ mở rộng (scale) theo chiều ngang.
- Tự chứa thông tin: Payload chứa đủ thông tin người dùng → giảm truy vấn DB.
- Linh hoạt: Hoạt động tốt với microservices — nhiều service có thể xác minh cùng một token.
- Cross-domain: Dễ dùng trong môi trường CORS, mobile app, SPA.
⚠️ Nhược điểm & Rủi ro cần lưu ý
| Vấn đề | Giải thích | Giải pháp |
|---|---|---|
| Không thu hồi được | Token hợp lệ cho đến khi hết hạn dù user đã logout | Đặt exp ngắn (15–30 phút) + Refresh Token |
| Payload có thể đọc được | Base64 ≠ mã hóa, ai cũng decode được | Không lưu thông tin nhạy cảm (mật khẩu, CCCD…) |
| Kích thước lớn | Payload càng nhiều claims, token càng to | Chỉ lưu claims cần thiết |
| Thuật toán yếu | alg: none hoặc HS256 có thể bị tấn công |
Dùng RS256 hoặc ES256 cho production |
🔐 Best Practices cho Production
- Dùng thuật toán RS256 hoặc ES256 (asymmetric) thay vì HS256
- Access Token hết hạn sau 15–30 phút
- Refresh Token hết hạn sau 7–30 ngày, lưu trong
httpOnly cookie - Không lưu thông tin nhạy cảm trong payload
- Validate đầy đủ: chữ ký +
exp+iss(issuer) +aud(audience) - Implement token rotation: mỗi lần refresh, cấp Refresh Token mới và vô hiệu hóa cái cũ
🔄 Access Token vs Refresh Token
| Access Token | Refresh Token | |
|---|---|---|
| Mục đích | Xác thực mỗi API request | Lấy Access Token mới |
| Thời hạn | Ngắn (15–30 phút) | Dài (7–30 ngày) |
| Nơi lưu | Memory / Authorization header | httpOnly cookie |
| Gửi đến | Resource Server (API) | Auth Server |
| Khi bị lộ | Thiệt hại giới hạn (hết hạn nhanh) | Nguy hiểm hơn — cần revoke ngay |
🆚 So sánh với Session-based Authentication
| Tiêu chí | JWT (Stateless) | Session (Stateful) |
|---|---|---|
| Lưu trữ phía server | ❌ Không cần | ✅ Cần (DB/Redis) |
| Khả năng mở rộng | ✅ Tốt (scale ngang dễ) | ⚠️ Khó hơn (cần shared session store) |
| Thu hồi token | ❌ Khó (cần blacklist) | ✅ Dễ (xóa session) |
| Phù hợp với | Microservices, Mobile, SPA | Web truyền thống, cần logout ngay lập tức |
| Kích thước | Lớn hơn (token trong header) | Nhỏ (chỉ session ID trong cookie) |
📚 Tài liệu tham khảo
- JWT.io — Giải thích chi tiết và công cụ decode
- RFC 7519 — JSON Web Token Specification
- OWASP — JWT Security Cheat Sheet
🔐 Chi tiết Flow Xác thực JWT trong Laravel
1. Auth Server — Xác minh username + password
Analogy: Giống như bảo vệ công ty kiểm tra thẻ nhân viên lần đầu — họ cần xác minh danh tính thật sự của bạn trước khi cấp thẻ.
Khi client gửi POST /api/login, Laravel xử lý qua Controller theo 4 bước:
Bước 1 — Tìm user trong DB
Laravel dùng Eloquent — không cần viết SQL thủ công:
// app/Http/Controllers/AuthController.php
public function login(Request $request)
{
$request->validate([
'email' => 'required|email',
'password' => 'required|string',
]);
// Eloquent tự generate: SELECT * FROM users WHERE email = ? LIMIT 1
$user = User::where('email', $request->email)->first();
if (! $user) {
return response()->json(['message' => 'Unauthorized'], 401);
}
Bước 2 — Xác minh password (bcrypt)
Password không bao giờ lưu dưới dạng plaintext. DB lưu dạng: $2y$12$abc...xyz (bcrypt hash).
Cơ chế bcrypt — 3 bước nội bộ:
- Lấy
saltđược nhúng sẵn trongstored_hash - Hash lại
password_nhap + saltvới cùng cost factor - So sánh kết quả với
stored_hash→ Không thể reverse, chỉ có thể so sánh
Laravel bọc toàn bộ logic này trong Hash::check():
// Hash::check() = bcrypt(password_nhap + salt) == stored_hash?
if (! Hash::check($request->password, $user->password)) {
return response()->json(['message' => 'Unauthorized'], 401);
}
// ❌ KHÔNG BAO GIỜ làm thế này:
// if ($request->password === $user->password) ← so sánh plaintext!
Bước 3 — Kiểm tra trạng thái tài khoản
if (! $user->is_active) {
return response()->json(['message' => 'Account disabled'], 403);
}
// Built-in Laravel: kiểm tra email_verified_at
if (! $user->hasVerifiedEmail()) {
return response()->json(['message' => 'Email not verified'], 403);
}
// Quá nhiều lần sai → dùng Laravel Throttle middleware hoặc RateLimiter
Bước 4 — Tạo JWT và trả về
JWTAuth::fromUser($user) sẽ tự động:
- Tạo Header (
alg,typ) - Tạo Payload (
sub= user ID,iat,exp, custom claims) - Ký số bằng
JWT_SECRET(HS256) hoặc private key (RS256)
$token = JWTAuth::fromUser($user);
return response()->json([
'access_token' => $token,
'token_type' => 'bearer',
'expires_in' => config('jwt.ttl') * 60, // giây
]);
}
Kết quả trả về client:
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer",
"expires_in": 3600
}
2. Resource Server — Xác minh JWT token
Analogy: Bảo vệ ở cửa các tầng sau chỉ cần kiểm tra chữ ký trên thẻ — không cần gọi HR (Auth Server) để xác minh lại.
Toàn bộ logic xác minh được đóng gói trong Middleware — Controller không cần xử lý.
Đăng ký Middleware
// bootstrap/app.php (Laravel 11+)
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'auth.jwt' => \Tymon\JWTAuth\Http\Middleware\Authenticate::class,
]);
})
// routes/api.php
Route::middleware('auth.jwt')->group(function () {
Route::get('/profile', [UserController::class, 'profile']);
Route::get('/orders', [OrderController::class, 'index']);
});
Bên trong Middleware tự động làm 5 bước:
Bước 1 — Parse & validate cấu trúc token
- Tách
Authorization: Bearer <token>→ lấy phần token - Tách token thành 3 phần:
header.payload.signature - Kiểm tra định dạng Base64URL hợp lệ
- Decode header → kiểm tra
alg(từ chốialg: none— lỗ hổng bảo mật nghiêm trọng)
Bước 2 — Xác minh chữ ký (quan trọng nhất)
-
Giải thích chi tiết
Đây là bước cốt lõi nhất của toàn bộ JWT. Hãy đi từ bản chất lên:
🔑 Câu hỏi cần trả lời: "Token này có bị ai giả mạo/sửa không?"
Phần 1 — Signature được tạo ra như thế nào? (Khi login)
Khi Auth Server tạo token, signature được sinh bằng công thức:
Signature = HMAC_SHA256( base64url(Header) + "." + base64url(Payload), JWT_SECRET )Ví dụ cụ thể:
Header (encoded): eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 Payload (encoded): eyJzdWIiOiIxIiwicm9sZSI6ImFkbWluIn0 Input vào hàm ký: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwicm9sZSI6ImFkbWluIn0" JWT_SECRET = "super-secret-key" → Signature = "SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"Token hoàn chỉnh gửi về client:
eyJhbGci... . eyJzdWIi... . SflKxwRJ... Header Payload Signature
Phần 2 — Xác minh chữ ký làm gì? (Khi nhận request)
Khi Resource Server nhận token, nó thực hiện đúng 1 thao tác:
"Tự tính lại Signature từ Header + Payload trong token, rồi so sánh với Signature đính kèm."
Token nhận được: Header = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 Payload = eyJzdWIiOiIxIiwicm9sZSI6ImFkbWluIn0 Signature_nhận = SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c Server tự tính lại: Signature_tính = HMAC_SHA256(Header + "." + Payload, JWT_SECRET) = SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c So sánh: Signature_nhận == Signature_tính ? → true ✅ Token hợp lệ → false ❌ Token bị giả mạo
Phần 3 — Tại sao sửa 1 ký tự trong Payload là bị phát hiện ngay?
Giả sử attacker intercept token và sửa role từ "user" thành "admin":
Payload gốc: { "sub": "1", "role": "user" } → hash X Payload giả: { "sub": "1", "role": "admin" } → hash Y (hoàn toàn khác)HMAC là hàm một chiều nhạy với mọi thay đổi — thay đổi 1 bit input → output thay đổi hoàn toàn (Avalanche Effect).
Attacker không thể tạo ra Signature mới hợp lệ vì không biết
JWT_SECRET. Nên khi server tính lại:HMAC(payload_giả, JWT_SECRET) = Y ≠ X (signature gốc) → false ❌ → TokenInvalidException
Phần 4 — HS256 vs RS256 — khác nhau chỗ nào?
HS256 (Symmetric) RS256 (Asymmetric) Ký bằng JWT_SECRETprivate keyVerify bằng JWT_SECRET(cùng key)public key(khác key)Ai có thể verify Ai biết secret đều verify được Ai cũng có thể verify với public key Rủi ro Lộ secret = mất hoàn toàn Lộ public key = không sao Dùng khi Monolith, đơn giản Microservices, nhiều service verify Với HS256 (mặc định của
tymon/jwt-auth):Ký: HMAC_SHA256(data, JWT_SECRET) → Auth Server dùng Verify: HMAC_SHA256(data, JWT_SECRET) → Resource Server dùng cùng keyVới RS256:
Ký: RSA_sign(data, private_key) → chỉ Auth Server có Verify: RSA_verify(data, public_key) → bất kỳ service nào cũng verify được
Phần 5 — Điểm quan trọng cần nắm
1. Signature KHÔNG mã hóa Payload — nó chỉ đảm bảo tính toàn vẹn (integrity). Payload vẫn đọc được bằng Base64 decode.
2. Server KHÔNG lưu token — toàn bộ xác minh dựa vào việc tính lại hash và so sánh. Đây là lý do JWT stateless.
3. Bảo mật phụ thuộc hoàn toàn vào
JWT_SECRET— key bị lộ = attacker có thể tự tạo token hợp lệ với bất kỳ payload nào. Phải giữ secret trong.env, không commit lên git.4.
alg: nonelà lỗ hổng chết người — một số thư viện cũ nếu không validatealg, attacker có thể gửi token vớialg: nonevà signature rỗng → server chấp nhận. Luôn whitelist algorithm được phép dùng.
Tóm gọn bằng 1 câu
Xác minh chữ ký = "Tôi tính lại hash từ Header+Payload bằng secret của tôi — nếu khớp với signature trong token, thì token này do tôi cấp và chưa bị ai sửa."
verify(
base64(header) + "." + base64(payload), ← nội dung cần kiểm tra
signature, ← chữ ký từ token
JWT_SECRET hoặc publicKey ← key để verify
) → true / false
→ Nếu payload bị sửa dù 1 ký tự → signature không khớp → reject ngay.
Bước 3 — Xác minh các claims
| Claim | Kiểm tra | Fail → |
|---|---|---|
exp |
now() < exp (hết hạn chưa?) |
401 Token Expired |
nbf |
now() >= nbf (chưa đến giờ dùng?) |
401 Token Not Yet Valid |
iss |
Đúng issuer đã cấu hình? | 401 Invalid Issuer |
aud |
Đúng audience của service này? | 401 Invalid Audience |
Bước 4 — Kiểm tra quyền (Authorization)
Dùng Laravel Gates hoặc Policies sau khi token đã verified:
// Trong Controller
public function deletePost(Post $post)
{
// Gate dùng role từ JWT payload (đã được gắn vào Auth user)
$this->authorize('delete', $post);
// hoặc
if (! auth()->user()->hasRole('admin')) {
abort(403, 'Forbidden');
}
}
Bước 5 — Gắn user vào request context
Sau khi verify xong, middleware tự gắn user vào Auth guard:
// Trong bất kỳ Controller nào (sau middleware)
public function profile()
{
$user = auth()->user(); // lấy full User model
$userId = auth()->id(); // lấy user ID từ payload 'sub'
return response()->json($user);
}
Xử lý lỗi trong Middleware (tự viết để hiểu rõ hơn)
// app/Http/Middleware/JwtMiddleware.php
public function handle(Request $request, Closure $next)
{
try {
// Bước 1+2+3 đều nằm trong dòng này:
$user = JWTAuth::parseToken()->authenticate();
} catch (TokenExpiredException $e) {
return response()->json(['message' => 'Token expired'], 401);
} catch (TokenInvalidException $e) {
// Sai chữ ký, sai cấu trúc, alg: none...
return response()->json(['message' => 'Token invalid'], 401);
} catch (JWTException $e) {
// Không có token trong header
return response()->json(['message' => 'Token absent'], 401);
}
return $next($request);
}
🗺️ Sơ đồ tổng thể trong Laravel
sequenceDiagram
participant C as 🖥️ Client
participant AC as 🔐 AuthController
participant DB as 🗄️ Database
participant MW as 🛡️ JWT Middleware
participant RC as 📦 ResourceController
C->>AC: POST /api/login { email, password }
AC->>DB: User::where('email')->first()
DB-->>AC: $user (hoặc null → 401)
AC->>AC: Hash::check(password, user.password)
Note over AC: bcrypt verify — không thể reverse
AC->>AC: Kiểm tra is_active, email_verified
AC->>AC: JWTAuth::fromUser($user)
Note over AC: Tạo header+payload, ký bằng JWT_SECRET
AC-->>C: { access_token: "eyJ..." }
Note over C: Lưu token (memory / httpOnly cookie)
C->>MW: GET /api/profile<br/>Authorization: Bearer eyJ...
MW->>MW: Parse token → verify signature
MW->>MW: Kiểm tra exp, iss, aud
MW->>MW: Auth::setUser($user)
MW->>RC: $next($request) ✅
RC->>RC: auth()->user() → lấy user
RC-->>C: 200 OK + { user data }
📊 Tóm tắt so sánh Auth Server vs Resource Server
| Auth Server (AuthController) | Resource Server (JWT Middleware) | |
|---|---|---|
| Dùng key nào | JWT_SECRET hoặc private key để ký |
JWT_SECRET hoặc public key để verify |
| Cần DB không | ✅ Có — tìm user, so sánh hash | ❌ Không — stateless hoàn toàn |
| Xác minh gì | Identity: "Bạn là ai?" | Authorization: "Bạn có quyền không?" |
| Laravel class | AuthController • Hash::check() |
JWTAuth::parseToken()->authenticate() |
| Khi thất bại | 401 Unauthorized / 403 Forbidden | 401 Token Expired / Token Invalid |