Xây dựng cơ chế JWT Refresh Token nâng cao và Quản lý Blacklist Token với Redis trong Node.js
Lỗ hổng chí mạng của Stateless JWT và Sự ra đời của kiến trúc Refresh Token
JWT (JSON Web Token) được thiết kế theo mô hình stateless – nghĩa là server không lưu trữ bất kỳ trạng thái nào về token đã cấp phát. Mọi thông tin xác thực đều được nhúng ngay trong token, và server chỉ việc xác minh chữ ký để tin rằng token là hợp lệ.
Điều này tạo ra một lỗ hổng bảo mật nghiêm trọng: một khi JWT đã được phát hành, bạn không thể thu hồi nó cho đến khi nó hết hạn. Nếu kẻ tấn công đánh cắp được Access Token của người dùng, chúng có thể dùng nó để truy cập hệ thống cho đến khi token hết hiệu lực – có thể là hàng giờ hoặc thậm chí hàng ngày nếu bạn cài thời gian sống quá dài.

💡 Tình huống thực tế: Người dùng bấm nút “Đăng xuất” nhưng Access Token vẫn còn hạn. Token đó vẫn có thể được sử dụng để gọi API trong khoảng thời gian còn lại. Điều này đồng nghĩa với việc tính năng đăng xuất chỉ có hiệu lực trên giao diện người dùng, chứ không thực sự vô hiệu hóa phiên làm việc ở phía server.
Giải pháp cho bài toán này là sử dụng kiến trúc Refresh Token – một cặp token gồm:
| Loại Token | Thời gian sống | Vai trò |
|---|---|---|
| Access Token | Ngắn (5–15 phút) | Xác thực người dùng cho mỗi request API |
| Refresh Token | Dài (7–30 ngày) | Cấp phát Access Token mới khi token cũ hết hạn |
Với cách tiếp cận này, Access Token có thời gian sống ngắn nên thiệt hại khi bị đánh cắp được giới hạn trong vài phút. Refresh Token tuy sống lâu hơn nhưng sẽ được lưu trữ an toàn trong httpOnly Cookie và chỉ được sử dụng duy nhất cho việc gia hạn token.
Tuy nhiên, vẫn còn một câu hỏi lớn: Làm thế nào để thu hồi một Access Token vẫn còn hạn khi người dùng đăng xuất hoặc đổi mật khẩu? Câu trả lời nằm ở cơ chế Blacklist Token với Redis – sẽ được trình bày chi tiết ở phần sau.
1. Thiết kế luồng đi (Workflow) của cặp bài trùng Access và Refresh Token

Hiểu được luồng hoạt động của toàn bộ hệ thống là bước đầu tiên và quan trọng nhất trước khi bắt tay vào viết code.
Biểu đồ Sequence
Client Server Redis
| | |
|--- Login (user/pass) -->| |
| |--- Create Token Pair --->|
| |<-- Store Refresh Token --|
|<-- {access, refresh} --| |
| | |
|--- Request API + AT --->| |
| |--- Check Blacklist ---->|
| |<-- Not blacklisted -----|
| |--- Verify AT -----------|
|<-- Response -----------| |
| | |
|--- /refresh-token + RT->| |
| |--- Verify RT -----------|
| |--- Check RT exists ----->|
| |<-- Yes -----------------|
| |--- Rotate: delete old --|
| |--- Create new pair ----->|
|<-- {new_access, new_refresh}--| |
| | |
|--- Logout ------------->| |
| |--- Add AT to blacklist ->|
| |--- Delete RT ----------->|
|<-- Logout success -----| |
Giải thích chi tiết từng bước
Bước 1 – Đăng nhập: Client gửi thông tin đăng nhập. Server xác thực, sinh cặp Access Token (thời gian sống ngắn) và Refresh Token (thời gian sống dài). Refresh Token được lưu vào Redis với TTL tương ứng, Access Token được trả về cho client.
Bước 2 – Gọi API: Client gửi Access Token trong Header Authorization. Middleware kiểm tra token có bị blacklist không (bằng cách query Redis), sau đó xác thực token. Nếu hợp lệ, request được xử lý.
Bước 3 – Gia hạn token: Khi Access Token hết hạn (server trả về 401), Client gửi Refresh Token lên endpoint /refresh-token. Server xác thực Refresh Token, kiểm tra sự tồn tại trong Redis, sau đó thực hiện Refresh Token Rotation (hủy token cũ, tạo cặp mới) và trả về cho client.
Bước 4 – Đăng xuất: Client gọi endpoint /logout. Server thêm Access Token hiện tại vào Blacklist Redis và xóa Refresh Token khỏi Redis. Token bị blacklist sẽ tự động bị xóa khỏi Redis sau khi hết hạn tự nhiên.
2. Hiện thực hóa mã nguồn Endpoint /refresh-token an toàn
2.1. Cài đặt thư viện
npm install jsonwebtoken@9.x ioredis@5.x cookie-parser express
npm install -D @types/jsonwebtoken @types/cookie-parser @types/express typescript ts-node
2.2. Cấu hình Redis Client
// config/redis.ts
import Redis from 'ioredis';
export const redis = new Redis({
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379'),
password: process.env.REDIS_PASSWORD || undefined,
retryStrategy: (times: number) => {
// Chiến lược retry khi Redis mất kết nối
return Math.min(times * 50, 2000);
},
maxRetriesPerRequest: 3,
});
redis.on('connect', () => console.log('✅ Redis connected'));
redis.on('error', (err) => console.error('❌ Redis error:', err));
2.3. Hàm tạo cặp token
Để hiểu rõ hơn về cơ chế mã hóa JWT cơ bản, bạn có thể tham khảo bài viết Xác thực JWT trong Node.js: Hướng dẫn từ Zero đến Deploy để nắm vững nền tảng trước khi áp dụng các kỹ thuật nâng cao dưới đây.
// services/token.service.ts
import jwt from 'jsonwebtoken';
import { randomBytes } from 'crypto';
import { redis } from '../config/redis';
export const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET!;
export const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET!;
export const ACCESS_TOKEN_EXPIRY = '15m'; // 15 phút
export const REFRESH_TOKEN_EXPIRY = 7 * 24 * 60 * 60; // 7 ngày (giây)
interface TokenPayload {
userId: string;
email: string;
}
interface TokenPair {
accessToken: string;
refreshToken: string;
accessTokenExpiry: number; // Unix timestamp (giây)
refreshTokenExpiry: number; // Unix timestamp (giây)
}
export function generateTokenPair(payload: TokenPayload): TokenPair {
const now = Math.floor(Date.now() / 1000);
const accessExp = now + 15 * 60; // 15 phút
const refreshExp = now + REFRESH_TOKEN_EXPIRY;
// Access Token: ngắn hạn
const accessToken = jwt.sign(
{ ...payload, exp: accessExp },
ACCESS_TOKEN_SECRET,
{ algorithm: 'HS256' }
);
// Refresh Token: dài hạn, có jti (JWT ID) để định danh duy nhất
const jti = randomBytes(16).toString('hex');
const refreshToken = jwt.sign(
{ ...payload, jti, exp: refreshExp },
REFRESH_TOKEN_SECRET,
{ algorithm: 'HS256' }
);
return {
accessToken,
refreshToken,
accessTokenExpiry: accessExp,
refreshTokenExpiry: refreshExp,
};
}
Giải thích code:
- Access Token có thời gian sống 15 phút – đủ ngắn để giới hạn thiệt hại khi bị đánh cắp, nhưng đủ dài để không làm phiền người dùng.
- Refresh Token có thời gian sống 7 ngày và chứa
jti(JWT ID) – một định danh duy nhất để có thể quản lý từng token riêng lẻ trong Redis. - Theo RFC 7519, claim
expđược tính bằng số giây kể từ Unix Epoch (không phải mili-giây). Điều này rất quan trọng khi tính toán TTL cho Redis.
2.4. Lưu Refresh Token vào Redis
// services/token.service.ts (tiếp theo)
export async function storeRefreshToken(
userId: string,
jti: string,
ttlSeconds: number
): Promise
<void> {
const key = `refresh:${userId}:${jti}`;
await redis.setex(key, ttlSeconds, 'active');
}
export async function getRefreshToken(
userId: string,
jti: string
): Promise<string | null> {
const key = `refresh:${userId}:${jti}`;
return await redis.get(key);
}
export async function deleteRefreshToken(
userId: string,
jti: string
): Promise
<void> {
const key = `refresh:${userId}:${jti}`;
await redis.del(key);
}
// Hàm hỗ trợ revoke tất cả token của user khi phát hiện tấn công
export async function revokeAllUserTokens(userId: string): Promise
<void> {
const keys = await redis.keys(`refresh:${userId}:*`);
if (keys.length > 0) {
await redis.del(...keys);
}
// Đồng thời blacklist tất cả access token đang hoạt động
// Có thể implement thêm logic để quét và blacklist
console.log(`🔒 Revoked all tokens for user ${userId}`);
}
2.5. Xử lý Refresh Token Rotation
Refresh Token Rotation là kỹ thuật bảo mật quan trọng: mỗi Refresh Token chỉ được sử dụng đúng một lần duy nhất. Khi client dùng Refresh Token để lấy Access Token mới, server sẽ hủy token cũ và phát hành một cặp token hoàn toàn mới.
// routes/auth.routes.ts
import express from 'express';
import jwt from 'jsonwebtoken';
import { redis } from '../config/redis';
import {
generateTokenPair,
storeRefreshToken,
deleteRefreshToken,
getRefreshToken,
revokeAllUserTokens,
REFRESH_TOKEN_EXPIRY,
REFRESH_TOKEN_SECRET
} from '../services/token.service';
const router = express.Router();
router.post('/refresh-token', async (req, res) => {
try {
// Lấy Refresh Token từ httpOnly Cookie (an toàn hơn JSON body)
const refreshToken = req.cookies?.refreshToken;
if (!refreshToken) {
return res.status(401).json({ error: 'Refresh token không tồn tại' });
}
// 1. Xác thực Refresh Token
let decoded: any;
try {
decoded = jwt.verify(refreshToken, REFRESH_TOKEN_SECRET);
} catch (error) {
return res.status(403).json({ error: 'Refresh token không hợp lệ hoặc đã hết hạn' });
}
const { userId, email, jti } = decoded;
// 2. Kiểm tra token có tồn tại trong Redis không
const stored = await getRefreshToken(userId, jti);
if (!stored) {
// ⚠️ Token đã bị hủy hoặc không tồn tại → có thể đang bị tấn công!
// Trong trường hợp này, nên hủy tất cả token của user để bảo vệ
await revokeAllUserTokens(userId);
return res.status(403).json({ error: 'Phiên đăng nhập không hợp lệ' });
}
// 3. Xóa Refresh Token cũ khỏi Redis (Rotation)
await deleteRefreshToken(userId, jti);
// 4. Tạo cặp token mới
const newTokens = generateTokenPair({ userId, email });
// 5. Lưu Refresh Token mới vào Redis
const decodedRefresh = jwt.decode(newTokens.refreshToken) as any;
await storeRefreshToken(
userId,
decodedRefresh.jti, // Lấy đúng jti từ token mới
REFRESH_TOKEN_EXPIRY
);
// 6. Trả về Access Token mới (Refresh Token mới gửi qua Cookie)
res.cookie('refreshToken', newTokens.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: REFRESH_TOKEN_EXPIRY * 1000,
});
return res.json({
accessToken: newTokens.accessToken,
accessTokenExpiry: newTokens.accessTokenExpiry,
});
} catch (error) {
console.error('Refresh token error:', error);
return res.status(500).json({ error: 'Lỗi server' });
}
});
export default router;
🔥 Phát hiện tấn công: Nếu một Refresh Token cũ được gửi lên server lần thứ hai (sau khi đã bị xóa trong lần rotation đầu tiên), điều đó có nghĩa là token đã bị đánh cắp và kẻ tấn công đang cố dùng lại. Trong tình huống này, server sẽ hủy toàn bộ phiên đăng nhập của user đó.
3. Xây dựng lá chắn Blacklist Token bằng Redis để xử lý Logout
3.1. Lưu token vào Redis với TTL trùng khít thời gian còn lại

Khi người dùng đăng xuất, chúng ta cần thêm Access Token hiện tại vào danh sách đen. Điểm quan trọng là TTL của Redis phải khớp chính xác với thời gian còn lại của token để tránh lãng phí bộ nhớ.
// services/blacklist.service.ts
import jwt from 'jsonwebtoken';
import { redis } from '../config/redis';
export async function blacklistToken(accessToken: string): Promise
<void> {
try {
// Decode token để lấy payload (không cần verify vì token đã được verify từ middleware)
const decoded = jwt.decode(accessToken) as any;
if (!decoded || !decoded.exp) {
throw new Error('Token không có thông tin hết hạn');
}
const now = Math.floor(Date.now() / 1000);
const remainingTTL = Math.max(0, decoded.exp - now);
if (remainingTTL <= 0) {
// Token đã hết hạn, không cần blacklist
return;
}
// Sử dụng jti hoặc toàn bộ token làm key
const key = `blacklist:${decoded.jti || accessToken}`;
// ⚠️ Lưu ý: remainingTTL là số giây, khớp với định dạng exp của JWT
await redis.setex(key, remainingTTL, 'revoked');
console.log(`🔒 Token blacklisted, TTL: ${remainingTTL}s`);
} catch (error) {
console.error('Blacklist error:', error);
throw error;
}
}
export async function isTokenBlacklisted(accessToken: string): Promise
<boolean> {
try {
const decoded = jwt.decode(accessToken) as any;
if (!decoded) return false;
const key = `blacklist:${decoded.jti || accessToken}`;
const result = await redis.exists(key);
return result === 1;
} catch (error) {
console.error('Check blacklist error:', error);
// ⚠️ Fallback: nếu Redis lỗi, cho phép request đi qua (không chặn oan)
return false;
}
}
🔍 Kiểm chứng quan trọng: Claim exp trong JWT được tính bằng giây kể từ Unix Epoch. Hàm jwt.decode() trả về giá trị exp dưới dạng số giây. Vì vậy, khi tính remainingTTL = decoded.exp - now, kết quả cũng là số giây – hoàn toàn khớp với tham số ex của redis.setex().
3.2. Middleware checkBlacklist bọc đầu mọi route bảo mật
// middlewares/auth.middleware.ts
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { isTokenBlacklisted } from '../services/blacklist.service';
import { ACCESS_TOKEN_SECRET } from '../services/token.service';
export interface AuthRequest extends Request {
user?: any;
}
export async function authenticate(
req: AuthRequest,
res: Response,
next: NextFunction
): Promise
<void> {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
res.status(401).json({ error: 'Không tìm thấy Access Token' });
return;
}
const token = authHeader.split(' ')[1];
// 🔥 Bước 1: Kiểm tra Blacklist TRƯỚC KHI xác thực
const isBlacklisted = await isTokenBlacklisted(token);
if (isBlacklisted) {
res.status(403).json({ error: 'Token đã bị thu hồi' });
return;
}
// 🔥 Bước 2: Xác thực token
try {
const decoded = jwt.verify(token, ACCESS_TOKEN_SECRET);
req.user = decoded;
next();
} catch (error: any) {
if (error.name === 'TokenExpiredError') {
res.status(401).json({ error: 'Access Token đã hết hạn' });
} else {
res.status(403).json({ error: 'Access Token không hợp lệ' });
}
}
} catch (error) {
console.error('Auth middleware error:', error);
res.status(500).json({ error: 'Lỗi xác thực' });
}
}
3.3. Endpoint Logout
// routes/auth.routes.ts (tiếp theo)
import { blacklistToken } from '../services/blacklist.service';
import { authenticate } from '../middlewares/auth.middleware';
import { deleteRefreshToken } from '../services/token.service';
router.post('/logout', authenticate, async (req: AuthRequest, res) => {
try {
const authHeader = req.headers.authorization!;
const accessToken = authHeader.split(' ')[1];
// 1. Thêm Access Token vào Blacklist
await blacklistToken(accessToken);
// 2. Xóa Refresh Token khỏi Redis (nếu có trong Cookie)
const refreshToken = req.cookies?.refreshToken;
if (refreshToken) {
try {
const decoded = jwt.decode(refreshToken) as any;
if (decoded?.userId && decoded?.jti) {
await deleteRefreshToken(decoded.userId, decoded.jti);
}
} catch (e) {
// Nếu decode lỗi, bỏ qua
}
}
// 3. Xóa Cookie Refresh Token
res.clearCookie('refreshToken', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
});
return res.json({ message: 'Đăng xuất thành công' });
} catch (error) {
console.error('Logout error:', error);
return res.status(500).json({ error: 'Lỗi đăng xuất' });
}
});
4. Xử lý Refresh Token trên Client-side
Để hệ thống hoạt động trơn tru, phía client cần tự động gọi refresh token khi nhận được lỗi 401 từ server. Dưới đây là cách implement với Axios Interceptor:
// client/axios.interceptor.ts
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
// Định nghĩa interface để lưu trạng thái refresh
let isRefreshing = false;
let refreshSubscribers: ((token: string) => void)[] = [];
// Hàm xử lý queue request khi đang refresh
function onRefreshed(token: string) {
refreshSubscribers.forEach(callback => callback(token));
refreshSubscribers = [];
}
// Hàm thêm request vào queue
function addRefreshSubscriber(callback: (token: string) => void) {
refreshSubscribers.push(callback);
}
// Axios instance
const apiClient = axios.create({
baseURL: process.env.API_BASE_URL,
timeout: 30000,
});
// Request Interceptor: thêm Access Token vào Header
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const token = localStorage.getItem('accessToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Response Interceptor: xử lý refresh token khi nhận 401
apiClient.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
// Nếu lỗi 401 và chưa retry
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
// Nếu đang refresh, đợi và gửi lại request sau khi có token mới
return new Promise((resolve) => {
addRefreshSubscriber((newToken: string) => {
originalRequest.headers.Authorization = `Bearer ${newToken}`;
resolve(apiClient.request(originalRequest));
});
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
// Gọi API refresh token (lấy từ cookie httpOnly)
const refreshResponse = await axios.post('/api/auth/refresh-token', {}, {
withCredentials: true, // Quan trọng: gửi kèm cookie
});
const { accessToken } = refreshResponse.data;
// Lưu Access Token mới
localStorage.setItem('accessToken', accessToken);
// Cập nhật header cho request gốc
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
// Thông báo cho các request đang chờ
onRefreshed(accessToken);
// Gửi lại request gốc
return apiClient.request(originalRequest);
} catch (refreshError) {
// Refresh token thất bại → đăng xuất
localStorage.removeItem('accessToken');
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);
export default apiClient;
Giải thích: Code trên giải quyết vấn đề Concurrent Requests – khi nhiều request cùng nhận 401, chỉ một request duy nhất gọi /refresh-token, các request còn lại đợi và dùng chung token mới.
Lỗi thường gặp và cách khắc phục
❌ Lỗi 1: Redis bị phình to dung lượng bộ nhớ do lưu Blacklist vô thời hạn
Nguyên nhân: Khi chèn Access Token bị hủy vào Redis, lập trình viên quên không đặt thời gian tự hủy TTL, khiến RAM server tích tụ rác vô hạn.
Cách khắc phục: Luôn tính toán TTL động dựa trên claim exp của token và truyền vào redis.setex():
const remainingTTL = Math.max(0, decoded.exp - Math.floor(Date.now() / 1000));
await redis.setex(`blacklist:${jti}`, remainingTTL, 'revoked');
❌ Lỗi 2: Nhầm lẫn giữa giây và mili-giây khi tính TTL
Nguyên nhân: Claim exp trong JWT là số giây, nhưng Date.now() trả về mili-giây.
Cách khắc phục: Luôn chuyển Date.now() về giây bằng cách chia cho 1000:
const nowInSeconds = Math.floor(Date.now() / 1000);
const remainingTTL = decoded.exp - nowInSeconds;
❌ Lỗi 3: Không xử lý fallback khi Redis bị sập
Nguyên nhân: Middleware gọi Redis cho mọi request. Nếu Redis gặp sự cố, toàn bộ hệ thống bị tê liệt.
Cách khắc phục: Bọc kiểm tra Redis trong try/catch và có cơ chế fallback:
try {
const isBlacklisted = await isTokenBlacklisted(token);
if (isBlacklisted) { /* chặn */ }
} catch (error) {
// Redis lỗi: cho phép request đi qua (hoặc log để giám sát)
console.error('Redis unavailable, skipping blacklist check');
}
❌ Lỗi 4: Race condition khi refresh token ở client
Nguyên nhân: Nhiều request cùng nhận 401 và gọi refresh token đồng thời, gây lãng phí và xung đột.
Cách khắc phục: Sử dụng cơ chế queue như đã trình bày trong phần 4 (Client-side Interceptor) để chỉ gọi refresh một lần duy nhất.
Lưu ý quan trọng
✅ Luôn lưu trữ Refresh Token trong Cookie với thuộc tính httpOnly: true, secure: true, và sameSite: 'strict' để triệt tiêu hoàn toàn nguy cơ bị hack qua XSS.
✅ Triển khai Refresh Token Rotation – mỗi Refresh Token chỉ được dùng đúng một lần duy nhất – để phát hiện sớm hành vi rò rỉ thiết bị.
✅ Đặt thời gian sống cho Access Token cực ngắn (5–15 phút) và Refresh Token dài hơn (7–30 ngày). Theo khuyến nghị từ RFC 8725 (JWT Best Current Practices), thời gian sống của Access Token nên được giữ ở mức tối thiểu cần thiết.
✅ Sử dụng jti (JWT ID) làm key cho Redis thay vì toàn bộ token để tiết kiệm bộ nhớ.
✅ Theo dõi dung lượng Redis và thiết lập cảnh báo khi bộ nhớ vượt ngưỡng 80%. Redis official benchmark cho thấy Redis có thể xử lý hơn 100,000 operations/giây với latency dưới 1ms trên hardware tiêu chuẩn.
✅ Luôn kiểm tra Blacklist trước khi xác thực token để tránh tốn công xác minh chữ ký cho token đã bị thu hồi.
Câu hỏi thường gặp (FAQ)
1. Tại sao đã dùng JWT (vốn là stateless) mà lại phải vác thêm Redis (stateful) vào làm gì cho phức tạp?
Đây là câu hỏi rất chính đáng. JWT stateless mang lại lợi ích về scalability – server không cần lưu trạng thái phiên, giúp việc mở rộng hệ thống theo chiều ngang trở nên dễ dàng.
Tuy nhiên, stateless cũng là con dao hai lưỡi: bạn không thể thu hồi token đã cấp phát. Việc thêm Redis (stateful) chỉ cho một mục đích duy nhất: quản lý danh sách đen (blacklist) và lưu Refresh Token. Đây là sự đánh đổi hợp lý:
- Access Token vẫn stateless – xác thực nhanh, không cần query DB.
- Chỉ Blacklist và Refresh Token là stateful – được Redis xử lý trong < 2ms.
Kết quả là bạn có được cả hai: tốc độ của stateless và khả năng thu hồi của stateful.
2. Điều gì xảy ra nếu cụm Redis Server làm Blacklist bị sập đột ngột? Hệ thống nên ứng phó ra sao?
Đây là tình huống cực kỳ nghiêm trọng cần được chuẩn bị trước:
| Mức độ | Giải pháp |
|---|---|
| Phòng ngừa | Triển khai Redis Cluster hoặc Redis Sentinel để có tính sẵn sàng cao |
| Phát hiện | Monitor Redis với các công cụ như Prometheus + Grafana |
| Ứng phó | Middleware nên có cơ chế fallback: nếu Redis không trả lời trong timeout (ví dụ 500ms), cho phép request đi qua nhưng ghi log để cảnh báo |
| Phục hồi | Khi Redis hoạt động trở lại, cần có cơ chế đồng bộ lại danh sách đen từ nguồn dự phòng (ví dụ: database) |
Code fallback mẫu:
async function isTokenBlacklistedWithFallback(token: string): Promise
<boolean> {
try {
const result = await Promise.race([
isTokenBlacklisted(token),
new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 500))
]);
return result as boolean;
} catch (error) {
// Redis timeout hoặc lỗi → fallback: cho phép request đi qua
console.warn('⚠️ Redis blacklist check failed, allowing request');
return false;
}
}

Kết luận
Xây dựng một hệ thống xác thực an toàn không chỉ dừng lại ở việc biết cách ký và xác minh JWT. Bài viết này đã đi sâu vào hai kỹ thuật cốt lõi để nâng cấp hệ thống đăng nhập của bạn lên chuẩn bảo mật doanh nghiệp:
- Cơ chế Refresh Token với Rotation – giúp gia hạn phiên đăng nhập tự động mà không bắt người dùng đăng nhập lại, đồng thời phát hiện token bị đánh cắp.
- Blacklist Token với Redis – cho phép thu hồi token từ xa khi người dùng đăng xuất hoặc đổi mật khẩu.
- Client-side Interceptor – đảm bảo trải nghiệm người dùng mượt mà với cơ chế tự động refresh và xử lý concurrent requests.
Sự kết hợp giữa stateless JWT (tốc độ) và stateful Redis (khả năng thu hồi) tạo nên một kiến trúc vừa nhanh, vừa an toàn, vừa có khả năng mở rộng – điều mà các bài viết thông thường trên mạng thường bỏ qua khi chỉ lưu Refresh Token trong MySQL/PostgreSQL.
Theo Redis official benchmark, Redis có thể xử lý hơn 100,000 operations/giây với latency dưới 1ms trên hardware tiêu chuẩn, giúp việc kiểm tra blacklist trở nên cực kỳ nhanh chóng và không ảnh hưởng đến hiệu suất hệ thống.
📌 Tài liệu tham khảo chính thức
- RFC 7519 – JSON Web Token (JWT) Specification – Đặc tả chuẩn của IETF về JWT, làm điểm tựa lý thuyết cho giải pháp Blacklist.
- RFC 8725 – JWT Best Current Practices – Hướng dẫn thực hành tốt nhất cho việc triển khai JWT an toàn.
- Redis Official Documentation – SETEX Command – Tài liệu chính thức về lệnh
SETEXdùng để lưu key với TTL. - Redis Official Benchmark – Số liệu hiệu năng Redis trong thực tế.