Hướng dẫn xác thực Web với JWT: Thực chiến từ A-Z
Tìm hiểu cách sử dụng JWT cho Web Authentication: Kiến trúc Access & Refresh Token, lưu trữ an toàn chống XSS/CSRF và cài đặt middleware thực tế.
JSON Web Token (JWT) là một trong những chuẩn xác thực phổ biến nhất trong thế giới phát triển web hiện đại. Từ các ứng dụng Single Page Application (React, Vue, Svelte) cho đến hệ thống Microservices hay Mobile Apps, JWT xuất hiện ở hầu khắp mọi kiến trúc xác thực.
Tuy nhiên, hiểu và sử dụng JWT đúng cách và an toàn lại là bài toán khiến không ít lập trình viên đau đầu: Nên lưu JWT ở đâu? Xử lý hết hạn phiên ra sao? Làm thế nào để chống cả XSS lẫn CSRF?
Hãy cùng tìm hiểu toàn diện kiến trúc Web Authentication với JWT từ góc nhìn trực quan nhất.
1. Giải thích theo kiểu ELI5: Tủ gửi đồ vs. Vòng tay VIP#
Để hiểu tại sao JWT ra đời, hãy so sánh nó với phương pháp Session truyền thống qua ví dụ tại một công viên giải trí:
Cách truyền thống (Session-based): “Tủ gửi đồ và thẻ số”#
- Khi vào cổng, lễ tân cấp cho bạn một chiếc thẻ số (Session ID).
- Mọi thông tin của bạn (Tên, gói vé VIP, giờ vào) được ghi vào một cuốn sổ cái tổng ở quầy tiếp tân (Database hoặc Redis).
- Mỗi khi bạn muốn chơi tàu lượn, nhân viên soát vé phải cầm bộ đàm gọi về quầy tiếp tân: “Kiểm tra giúp thẻ số 102 này có mua vé VIP không?”.
- Vấn đề: Khi công viên có 100.000 khách và 50 trò chơi, tổng đài quầy tiếp tân sẽ bị quá tải vì hàng triệu lượt gọi tra cứu sổ cái liên tục.
Cách hiện đại (JWT-based): “Vòng tay điện tử đóng dấu số”#
- Khi vào cổng, ban quản lý trao cho bạn một chiếc vòng đeo tay thông minh (JWT).
- Trên vòng tay in sẵn thông tin:
Tên: Alice, Hạng: VIP, Hết hạn: 18h00. - Đặc biệt, ban quản lý dán lên vòng tay một con dấu mật mã chống giả (Chữ ký điện tử - Digital Signature).
- Khi bạn chơi bất kỳ trò nào, nhân viên chỉ cần nhìn con dấu mật mã để xác nhận vòng tay là thật và đọc thẳng thông tin
VIPtrên vòng tay mà không cần gọi điện hỏi ai.
flowchart TD
subgraph Traditional["1. Session truyền thống - Stateful"]
Client1["Client"] -->|Gửi Session ID| Server1["App Server"]
Server1 -->|Tra cứu DB mỗi request| DB["Database / Redis<br/>Session Store"]
end
subgraph JWTWay["2. Xác thực JWT - Stateless"]
Client2["Client"] -->|Gửi JWT Token| Server2["App Server"]
Server2 -->|Tự xác thực chữ ký tại chỗ| Server2
end
2. Cấu trúc 3 phần của một JSON Web Token#
Một chuỗi JWT gồm 3 phần được mã hóa Base64URL và nối với nhau bằng dấu chấm (.):
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTYiLCJuYW1lIjoiQWxpY2UiLCJyb2xlIjoiYWRtaW4iLCJleHAiOjE3MDAwMDAwMDB9.4a5b6c...text- Header (Đầu trang): Chứa loại token (
JWT) và thuật toán ký mã hóa (ví dụHS256hoặcRS256). - Payload (Dữ liệu tải): Chứa các thông tin trao đổi (Claims) như
sub(User ID),role(Quyền hạn),exp(Thời gian hết hạn),iat(Thời điểm tạo). - Signature (Chữ ký mật mã): Được tạo ra bằng cách lấy Header + Payload băm với một Secret Key bí mật nằm trên server.
[!WARNING] Payload KHÔNG hề được mã hóa bí mật! Nó chỉ được encode Base64Url để truyền tải trên mạng. Bất kỳ ai cũng có thể giải mã và đọc được nội dung Payload. Tuyệt đối không lưu mật khẩu, thông tin thẻ tín dụng hay dữ liệu nhạy cảm vào Payload của JWT!
3. Kiến trúc chuẩn: Mô hình Dual-Token (Access & Refresh Token)#
Nếu chỉ sử dụng một Access Token duy nhất:
- Nếu đặt thời hạn quá dài (ví dụ 30 ngày) Rất nguy hiểm nếu token bị rò rỉ.
- Nếu đặt thời hạn quá ngắn (ví dụ 15 phút) Người dùng bị văng ra bắt đăng nhập lại liên tục.
Giải pháp tối ưu cho ứng dụng web là Mô hình 2 Token (Dual-Token Flow):
- Access Token: Có thời hạn rất ngắn (10 - 15 phút), dùng để gửi kèm vào mỗi API request.
- Refresh Token: Có thời hạn dài hơn (7 - 30 ngày), chỉ dùng khi Access Token hết hạn để xin cấp mới mà không làm phiền người dùng.
flowchart TD
User["Client Browser"]
AuthServer["Auth API Server"]
ResourceServer["Protected Resource Server"]
User -->|1. POST /login - Gửi thông tin đăng nhập| AuthServer
AuthServer -->|2. Trả về Access Token và Refresh Token| User
User -->|3. Gọi API với Bearer Access Token| ResourceServer
ResourceServer -->|4. Phản hồi dữ liệu 200 OK| User
User -->|5. Token hết hạn 401 Unauthorized| ResourceServer
User -->|6. POST /refresh với Cookie HttpOnly| AuthServer
AuthServer -->|7. Cấp Access Token mới - Token Rotation| User
4. Nên lưu trữ JWT ở đâu trên Web Client? (XSS vs. CSRF)#
Đây là chủ đề gây tranh luận nhiều nhất trong giới bảo mật web:
| Vị trí lưu trữ | Nguy cơ XSS | Nguy cơ CSRF | Đánh giá |
|---|---|---|---|
localStorage / sessionStorage | ❌ Rất cao (Bất kỳ mã JS độc hại nào cũng đọc và trộm được token) | ✅ An toàn (Trình duyệt không tự động gửi) | Không khuyến nghị cho Token dài hạn |
Cookie thường (Không HttpOnly) | ❌ Rất cao (Đọc được qua document.cookie) | ❌ Cao (Trình duyệt tự đính kèm) | Rất kém an toàn |
HttpOnly + Secure + SameSite=Strict/Lax Cookie | ✅ An toàn (JavaScript không thể đọc trộm) | ✅ An toàn (SameSite chặn request cross-site) | Khuyến nghị chuẩn cho Refresh Token |
| Bộ nhớ RAM (In-Memory JS Variable) | ✅ An toàn (Biến mất khi đóng tab, khó trộm hàng loạt) | ✅ An toàn (Không tự động gửi) | Khuyến nghị chuẩn cho Access Token |
Chiến lược lưu trữ Best Practice#
- Access Token: Lưu tạm trong Memory (State của React/Vue/Svelte).
- Refresh Token: Lưu trong
HttpOnlyCookie đi kèm cờSecurevàSameSite=Lax(hoặcStrict). - Khi người dùng F5 hoặc mở tab mới, client tự động gọi
/auth/refreshmột lần (Silent Refresh) để lấy Access Token nạp vào RAM.
5. Cài đặt thực tế với Node.js / Express & TypeScript#
5.1. Tạo Token khi Đăng nhập#
import { Request, Response } from 'express';
import jwt from 'jsonwebtoken';
const ACCESS_SECRET = process.env.ACCESS_TOKEN_SECRET || 'access_secret_key';
const REFRESH_SECRET = process.env.REFRESH_TOKEN_SECRET || 'refresh_secret_key';
export async function login(req: Request, res: Response) {
const { email, password } = req.body;
// 1. Xác thực user từ cơ sở dữ liệu (Database check)
const user = await validateUserCredentials(email, password);
if (!user) return res.status(401).json({ message: 'Sai email hoặc mật khẩu' });
// 2. Tạo Access Token (Ngắn hạn: 15 phút)
const accessToken = jwt.sign(
{ userId: user.id, role: user.role },
ACCESS_SECRET,
{ expiresIn: '15m' }
);
// 3. Tạo Refresh Token (Dài hạn: 7 ngày)
const refreshToken = jwt.sign(
{ userId: user.id },
REFRESH_SECRET,
{ expiresIn: '7d' }
);
// 4. Lưu Refresh Token vào HttpOnly Cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production', // Chỉ HTTPS
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 ngày
});
// 5. Trả Access Token trong JSON response
return res.json({ accessToken });
}typescript5.2. Middleware Xác thực Access Token#
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
export function authenticateJWT(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ message: 'Token không tồn tại hoặc không hợp lệ' });
}
const token = authHeader.split(' ')[1];
jwt.verify(token, process.env.ACCESS_TOKEN_SECRET!, (err, decodedUser) => {
if (err) {
// Hết hạn hoặc chữ ký không hợp lệ
return res.status(401).json({ message: 'Access Token đã hết hạn hoặc không hợp lệ' });
}
// Đính kèm thông tin user vào request để dùng ở các route tiếp theo
(req as any).user = decodedUser;
next();
});
}typescript5.3. Endpoint Cấp Mới Token (Refresh Token Endpoint)#
export async function refreshAccessToken(req: Request, res: Response) {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
return res.status(401).json({ message: 'Không tìm thấy Refresh Token' });
}
jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET!, (err: any, decoded: any) => {
if (err) return res.status(403).json({ message: 'Refresh Token không hợp lệ' });
// Tạo Access Token mới
const newAccessToken = jwt.sign(
{ userId: decoded.userId, role: decoded.role },
process.env.ACCESS_TOKEN_SECRET!,
{ expiresIn: '15m' }
);
return res.json({ accessToken: newAccessToken });
});
}typescript6. Những sai lầm bảo mật kinh điển cần tránh#
[!TIP] Checklist an toàn tuyệt đối cho JWT:
- Khóa bí mật mạnh: Luôn dùng secret key dài tối thiểu 256-bit hoặc sử dụng cặp khóa bất đối xứng RSA (
RS256) / Elliptic Curve (ES256).- Chặn thuật toán
"alg": "none": Các thư viện JWT cũ từng dính lỗ hổng chấp nhận token không cần ký nếu header ghi"alg": "none".- Kiểm tra Audience (
aud) và Issuer (iss): Xác minh token được phát hành bởi đúng máy chủ của bạn và dành cho đúng đối tượng client.- Triển khai Token Rotation: Mỗi khi Refresh Token được dùng để cấp Access Token mới, hãy hủy Refresh Token cũ và cấp một Refresh Token hoàn toàn mới.
7. Tổng kết#
Sử dụng JWT cho Web Authentication mang lại khả năng mở rộng (scalability) vượt trội cho các ứng dụng phân tán. Bằng cách áp dụng mô hình Dual-Token, lưu trữ Access Token trong Memory và Refresh Token trong HttpOnly Cookie, bạn sẽ xây dựng được một hệ thống xác thực vừa tiện lợi, mượt mà vừa đạt tiêu chuẩn bảo mật doanh nghiệp.