Xác thực JWT trong Node.js: Hướng dẫn từ Zero đến Deploy
Làm thế nào để xây dựng một hệ thống xác thực trong Node.js vừa an toàn, vừa dễ mở rộng mà không cần lưu trạng thái session ở server? Câu trả lời là JWT (JSON Web Token).
Trong bài viết này, chúng ta sẽ cùng xây dựng một hệ thống Login/Logout hoàn chỉnh bằng Node.js và Express, bao gồm cả cơ chế Access Token và Refresh Token – một yếu tố quan trọng mà nhiều bài hướng dẫn khác thường bỏ qua.
📌 Mục tiêu của bài viết:
- Hiểu rõ JWT là gì, cấu trúc và cách nó hoạt động.
- Cài đặt thành công jsonwebtoken để tạo và xác thực token.
- Xây dựng middleware bảo vệ API.
- Triển khai cơ chế Refresh Token an toàn để duy trì trạng thái đăng nhập lâu dài.
- Nắm vững các biện pháp bảo mật quan trọng như lưu token trong HttpOnly Cookie để tránh tấn công XSS.
Bài viết phù hợp với các bạn đã có kiến thức cơ bản về Node.js và Express (trình độ Intermediate). Tất cả code mẫu đều sử dụng jsonwebtoken phiên bản v9.x.
JWT là gì? Tại sao nó thống trị Web Authentication?

JWT (JSON Web Token) là một tiêu chuẩn mở (RFC 7519) để truyền tải thông tin an toàn giữa các bên dưới dạng JSON object. Thông tin này được ký số (digital signature), do đó có thể được xác thực và tin cậy.
Cấu trúc của một JWT
Một JWT được tạo thành từ 3 phần, ngăn cách nhau bởi dấu chấm (.):
[Header].[Payload].[Signature]
| Thành phần | Mô tả | Ví dụ |
|---|---|---|
| Header | Chứa metadata về token: loại token (typ) và thuật toán ký (alg), thường là HS256 hoặc RS256. |
{"alg": "HS256", "typ": "JWT"} |
| Payload | Chứa các claims (tuyên bố) về người dùng và các thông tin bổ sung. Đây là nơi ta lưu userId, role,… |
{"sub": "1234567890", "name": "John Doe", "iat": 1516239022} |
| Signature | Được tạo ra bằng cách mã hóa kết hợp Header, Payload và một Secret Key. Dùng để đảm bảo token không bị thay đổi trong quá trình truyền. |
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c |
⚠️ LƯU Ý: JWT được mã hóa bằng Base64Url, không phải mã hóa để bảo mật nội dung. Bất kỳ ai cũng có thể giải mã JWT để xem nội dung của Header và Payload. Vì vậy, tuyệt đối không lưu thông tin nhạy cảm như mật khẩu trong Payload.
*Tham khảo chi tiết: Auth0 JWT Structure
Tại sao JWT lại phổ biến?
- Stateless (Không trạng thái): Server không cần lưu session trong database hay memory. Mọi thông tin xác thực đều nằm gọn trong token, giúp việc mở rộng hệ thống (scale horizontally) trở nên dễ dàng.
- Portable (Di động): JWT có thể được sử dụng trên nhiều môi trường (web, mobile, IoT).
- Self-contained (Tự chứa): Token chứa luôn thông tin user, giảm số lần gọi database.
Luồng hoạt động của JWT trong ứng dụng Node.js

Để dễ hình dung, chúng ta cùng xem qua luồng hoạt động cơ bản của hệ thống xác thực JWT kết hợp Refresh Token và Cookie:
graph TD
A(Client) -- "1. Gửi email/password" --> B(Node.js Server)
B --> C{Verify user}
C -- "Success" --> D[Generate Access + Refresh Token]
D --> E[Lưu Refresh Token trong DB]
E --> F[Set HttpOnly Cookie chứa Refresh Token]
F --> G(Client lưu Cookie)
G --> H["Set Cookie tự động cho mọi request"]
H --> I(Client gọi API đến route cần xác thực)
I -- "Authorization: Bearer
<AccessToken>" --> J{Verify Access Token}
J -- "Valid" --> K(Trả về dữ liệu)
J -- "Expired" --> L(Client gọi /refresh endpoint)
L -- "Gửi Refresh Token từ Cookie" --> M{Verify Refresh Token}
M -- "Valid, kiểm tra DB" --> N[Generate new Access Token]
N --> O(Cập nhật Access Token mới)
O --> H
Giải thích các bước chính:
- Người dùng đăng nhập, server tạo Access Token (thời gian sống ngắn) và Refresh Token (thời gian sống dài).
- Refresh Token được lưu trong database và gửi về client qua HttpOnly Cookie.
- Client lưu Access Token trong memory, gửi qua Header
Authorizationmỗi khi gọi API. - Khi Access Token hết hạn, client dùng Refresh Token (tự động qua Cookie) gọi
/refreshđể lấy Access Token mới mà không cần đăng nhập lại.
Cài đặt và cấu hình jsonwebtoken
Bắt tay vào code thôi! Chúng ta sẽ xây dựng một REST API đơn giản.
Bước 1: Khởi tạo dự án và cài đặt dependencies
mkdir jwt-auth-tutorial
cd jwt-auth-tutorial
npm init -y
Cài đặt các package cần thiết:
npm install express jsonwebtoken bcryptjs cookie-parser cors dotenv
npm install -D nodemon
- express: Framework web cho Node.js.
- jsonwebtoken: Thư viện chính để tạo và xác thực JWT.
- bcryptjs: Dùng để hash mật khẩu.
- cookie-parser: Middleware parse Cookie.
- cors: Cho phép frontend gọi API (quan trọng khi dùng Cookie).
- dotenv: Load biến môi trường từ file
.env.
Bước 2: Cấu hình file .env
Tạo file .env ở thư mục gốc:
PORT=5000
ACCESS_TOKEN_SECRET="your_super_secret_access_key_change_this"
REFRESH_TOKEN_SECRET="your_super_secret_refresh_key_change_this"
NODE_ENV="development"
🚨 CẢNH BÁO: Hãy luôn dùng biến môi trường và thêm file .env vào .gitignore để tránh lộ secret key lên GitHub.
Bước 3: Tạo models user giả lập (chỉ demo)
LƯU Ý QUAN TRỌNG: Code dưới đây dùng mảng usersDB trong bộ nhớ để dễ hình dung. Trong ứng dụng thực tế, bạn phải thay thế bằng cơ sở dữ liệu thật như MongoDB, PostgreSQL, hoặc Redis (đặc biệt cho refresh token).
Tạo file users.js:
// users.js
const bcrypt = require('bcryptjs');
// ⚠️ Mảng tạm – chỉ dùng cho demo, không dùng trong production
const usersDB = [];
const createUser = async (email, password) => {
const hashedPassword = await bcrypt.hash(password, 10);
const newUser = {
id: usersDB.length + 1,
email,
password: hashedPassword,
refreshTokens: [] // Lưu refresh token đã cấp cho user
};
usersDB.push(newUser);
return newUser;
};
const findUserByEmail = (email) => usersDB.find(user => user.email === email);
const findUserById = (id) => usersDB.find(user => user.id === id);
module.exports = { usersDB, createUser, findUserByEmail, findUserById };
Bước 4: Viết hàm tạo Access Token và Refresh Token
Tạo file tokenUtils.js:
// tokenUtils.js
const jwt = require('jsonwebtoken');
const generateAccessToken = (user) => {
// Access Token thời gian sống ngắn: 15 phút (thực tế nên 5-15 phút)
return jwt.sign(
{ userId: user.id, email: user.email },
process.env.ACCESS_TOKEN_SECRET,
{ expiresIn: '15m' }
);
};
const generateRefreshToken = (user) => {
// Refresh Token thời gian sống dài: 7 ngày
return jwt.sign(
{ userId: user.id },
process.env.REFRESH_TOKEN_SECRET,
{ expiresIn: '7d' }
);
};
module.exports = { generateAccessToken, generateRefreshToken };
Giải thích code:
jwt.sign(payload, secret, options): tạo token.expiresIn: thời gian sống, có thể là số giây (60) hoặc chuỗi (“2 days”, “10h”, “7d”).
Bảo mật: Access Token vs Refresh Token

Đây là phần quan trọng nhất. Tại sao phải dùng hai loại token?
- Access Token (Short-lived): Thời gian sống ngắn (5-15 phút). Được gửi trong Header
Authorizationmỗi request. Nếu bị lộ, hacker chỉ dùng được trong thời gian ngắn. - Refresh Token (Long-lived): Thời gian sống dài (7 ngày – 1 năm). Dùng để lấy Access Token mới khi token cũ hết hạn. Không bao giờ gửi Refresh Token trên URL, mà lưu ở nơi an toàn hơn.
Lưu JWT ở đâu? HttpOnly Cookie mới là lựa chọn bảo mật
Nhiều hướng dẫn khác lưu JWT trong localStorage vì… dễ. Nhưng đây là con đường ngắn nhất đến thảm họa bảo mật.
❌ Tại sao không nên lưu trong LocalStorage?
localStoragecó thể truy cập trực tiếp bằng JavaScript qualocalStorage.getItem().- Nếu ứng dụng có lỗ hổng XSS, hacker chỉ cần chạy một đoạn script nhỏ là đánh cắp được toàn bộ token.
✅ Giải pháp an toàn: HttpOnly Cookie
- Cookie được gửi kèm mọi request đến đúng domain.
- Cookie không thể truy cập bằng JavaScript (
document.cookiekhông thấy được). - Kết luận: Bất kỳ lỗ hổng XSS nào cũng không thể đánh cắp token.
Vậy, chiến lược lưu trữ tốt nhất là gì?
Lưu Refresh Token trong HttpOnly Cookie và lưu Access Token trong memory (biến toàn cục trong store của frontend). Đây là sự kết hợp tối ưu giữa bảo mật và trải nghiệm người dùng.
Xây dựng API xác thực Node.js hoàn chỉnh
Giờ hãy code toàn bộ server trong file index.js. Chúng ta sẽ kết hợp xử lý lỗi tập trung – bạn có thể tham khảo thêm bài viết Xử lý lỗi trong Node.js.
// index.js
require('dotenv').config();
const express = require('express');
const cookieParser = require('cookie-parser');
const cors = require('cors');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const { findUserByEmail, createUser, findUserById, usersDB } = require('./users');
const { generateAccessToken, generateRefreshToken } = require('./tokenUtils');
const app = express();
const PORT = process.env.PORT || 5000;
// Middleware
app.use(express.json());
app.use(cookieParser());
app.use(cors({
origin: 'http://localhost:3000',
credentials: true
}));
// ========== AUTH MIDDLEWARE (bảo vệ route riêng tư) ==========
const authenticateToken = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // Bearer
<token>
if (!token) {
return res.status(401).json({ error: 'Access token required' });
}
// ⚠️ LUÔN chỉ định algorithm để tránh lỗ hổng bảo mật (CVE-2022-23540)
jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, { algorithms: ['HS256'] }, (err, user) => {
if (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Access token expired' });
}
return res.status(403).json({ error: 'Invalid access token' });
}
req.user = user;
next();
});
};
// ========== API ROUTES ==========
// [1] REGISTER
app.post('/api/auth/register', async (req, res) => {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: 'Email and password required' });
}
const existingUser = findUserByEmail(email);
if (existingUser) {
return res.status(409).json({ error: 'User already exists' });
}
try {
const newUser = await createUser(email, password);
res.status(201).json({ message: 'User created successfully', userId: newUser.id });
} catch (error) {
res.status(500).json({ error: 'Internal server error' });
}
});
// [2] LOGIN – tạo token và set cookie
app.post('/api/auth/login', async (req, res) => {
const { email, password } = req.body;
const user = findUserByEmail(email);
if (!user) {
return res.status(401).json({ error: 'Invalid email or password' });
}
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
return res.status(401).json({ error: 'Invalid email or password' });
}
const accessToken = generateAccessToken(user);
const refreshToken = generateRefreshToken(user);
// Lưu refresh token vào database (để thu hồi)
user.refreshTokens.push(refreshToken);
// Set HttpOnly Cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 ngày
});
res.json({ accessToken });
});
// [3] REFRESH TOKEN – cấp access token mới
app.post('/api/auth/refresh', (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
return res.status(401).json({ error: 'Refresh token required' });
}
jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET, { algorithms: ['HS256'] }, (err, decoded) => {
if (err) {
if (err.name === 'TokenExpiredError') {
return res.status(403).json({ error: 'Refresh token expired, please login again' });
}
return res.status(403).json({ error: 'Invalid refresh token' });
}
const user = findUserById(decoded.userId);
if (!user || !user.refreshTokens.includes(refreshToken)) {
return res.status(403).json({ error: 'Refresh token not found in database' });
}
const newAccessToken = generateAccessToken(user);
res.json({ accessToken: newAccessToken });
});
});
// [4] LOGOUT – xóa refresh token khỏi DB và cookie
app.post('/api/auth/logout', (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (refreshToken) {
for (const user of usersDB) {
const tokenIndex = user.refreshTokens.indexOf(refreshToken);
if (tokenIndex !== -1) {
user.refreshTokens.splice(tokenIndex, 1);
break;
}
}
}
res.clearCookie('refreshToken', { httpOnly: true, sameSite: 'strict' });
res.json({ message: 'Logged out successfully' });
});
// [5] PROTECTED ROUTE – yêu cầu xác thực
app.get('/api/protected', authenticateToken, (req, res) => {
res.json({ message: `Hello user ${req.user.email}, you have accessed a protected route!` });
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Giải thích các API quan trọng
| API | Mô tả |
|---|---|
POST /api/auth/login |
Tạo access + refresh token, lưu refresh token vào DB, set cookie, trả access token. |
POST /api/auth/refresh |
Lấy refresh token từ cookie, verify, kiểm tra trong DB, cấp access token mới. |
POST /api/auth/logout |
Xóa refresh token khỏi DB và xóa cookie. |
authenticateToken middleware |
Lấy token từ Header Authorization, verify với algorithms: ['HS256']. |
📌 Kiểm tra nhanh với Postman / cURL
-
Register
POST /api/auth/register
Body:{ "email": "test@example.com", "password": "123456" } -
Login
POST /api/auth/login(cùng body) → nhậnaccessToken, cookie được set tự động. -
Truy cập protected route
`
GET /api/protected
Header: `Authorization: Bearer -
Refresh token (sau 15 phút hoặc khi access token hết hạn)
POST /api/auth/refresh(không cần body, cookie tự gửi) → nhậnaccessTokenmới. -
Logout
POST /api/auth/logout→ cookie bị xóa, refresh token bị thu hồi.
Các lỗi thường gặp và cách khắc phục
1. Lộ Secret Key lên GitHub
- Cách fix: Luôn dùng
.env+.gitignore.
2. Quên Bearer khi gửi token
- Middleware cần tách chuỗi `”Bearer “`. Nếu quên, token sẽ sai format.
3. Không xử lý CSRF khi dùng Cookie
- Cách fix: Set
sameSite: 'strict'(như bài mẫu). Với ứng dụng đa miền, dùng thêm CSRF token.
4. Access Token hết hạn nhưng frontend không tự động refresh
- Cách fix: Trong frontend, bắt lỗi 401 → gọi
/refresh→ thất bại lại request cũ.
5. Thiếu kiểm tra refresh token trong database
- Cách fix: Luôn lưu refresh token vào DB và kiểm tra như code mẫu. Nếu không, token vẫn dùng được sau logout.
Best Practices cho hệ thống JWT Production
- ✅ Đặt thời gian hết hạn ngắn cho Access Token: 5–15 phút.
- ✅ Lưu Refresh Token trong DB để có thể Revoke (thu hồi).
- ✅ Luôn dùng HTTPS trong Production (cookie flag
Secure). - ✅ Luôn chỉ định thuật toán trong
jwt.verify– dùng{ algorithms: ['HS256'] }. - ✅ Nén và giới hạn kích thước Payload – JWT gửi kèm mỗi request.
- ✅ Xoay vòng Refresh Token (Token Rotation): Mỗi lần refresh, tạo refresh token mới, vô hiệu hóa token cũ.
- ✅ Tham khảo OWASP JWT Cheat Sheet để cập nhật các lỗ hổng mới nhất. (OWASP JWT Cheat Sheet)
Tổng kết
Chúng ta đã cùng nhau xây dựng thành công một hệ thống xác thực JWT hoàn chỉnh, an toàn và sẵn sàng deploy. Bạn đã nắm được:
- ✅ Cấu trúc và nguyên lý hoạt động của JWT.
- ✅ Cách cài đặt
jsonwebtokentrong Node.js. - ✅ Sự khác biệt và cách kết hợp Access Token và Refresh Token.
- ✅ Giải pháp bảo mật tối ưu với HttpOnly Cookie.
- ✅ Các lỗi thường gặp và best practices trong thực tế.
Hệ thống xác thực là nền tảng cho mọi ứng dụng. Hy vọng bài viết này sẽ giúp bạn tự tin xây dựng những dự án Node.js an toàn và chuyên nghiệp hơn.
Xem thêm:
Tài liệu tham khảo chính thức: