JWT authentication là chuẩn de-facto cho API hiện đại. Bài này không nhắc lại lý thuyết — đi thẳng vào implement đầy đủ trong Node.js, các pitfall bảo mật phổ biến và cách giải quyết refresh token rotation.
JWT có gì bên trong?
JWT là chuỗi 3 phần ngăn nhau bằng dấu chấm: header.payload.signature.
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMiLCJleHAiOjE3MzQwMDB9.k2j...
header = { "alg": "HS256", "typ": "JWT" }
payload = { "sub": "123", "exp": 1734000, "role": "user" }
signature = HMAC_SHA256( base64(header) + "." + base64(payload), SECRET )
Quan trọng: header và payload chỉ encode base64, KHÔNG mã hoá. Đừng đặt mật khẩu, số CMND vào payload — ai cũng decode được.
Setup nhanh trong Node.js
npm install jsonwebtoken cookie-parser bcrypt
Tạo cấu trúc tối thiểu:
// auth.js
import jwt from 'jsonwebtoken'
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET // strong random
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET // khác secret access
export function signAccessToken(userId, role) {
return jwt.sign({ sub: userId, role }, ACCESS_SECRET, {
expiresIn: '15m',
issuer: 'alodev.vn',
})
}
export function signRefreshToken(userId, jti) {
return jwt.sign({ sub: userId, jti }, REFRESH_SECRET, {
expiresIn: '30d',
issuer: 'alodev.vn',
})
}
export function verifyAccess(token) {
return jwt.verify(token, ACCESS_SECRET, { issuer: 'alodev.vn' })
}
Login flow đầy đủ
import bcrypt from 'bcrypt'
import { randomUUID } from 'node:crypto'
app.post('/auth/login', async (req, res) => {
const { email, password } = req.body
const user = await db.users.findByEmail(email)
if (!user || !await bcrypt.compare(password, user.password_hash)) {
return res.status(401).json({ error: { code: 'INVALID_CREDENTIALS' } })
}
const jti = randomUUID() // refresh token ID
await db.refreshTokens.insert({ // server-side state
jti, user_id: user.id,
expires_at: new Date(Date.now() + 30 * 86400_000),
})
const accessToken = signAccessToken(user.id, user.role)
const refreshToken = signRefreshToken(user.id, jti)
// Refresh token vào httpOnly cookie
res.cookie('rt', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
path: '/auth',
maxAge: 30 * 86400_000,
})
res.json({ access_token: accessToken })
})
Middleware xác thực
export function requireAuth(req, res, next) {
const auth = req.headers.authorization
if (!auth || !auth.startsWith('Bearer ')) {
return res.status(401).json({ error: { code: 'NO_TOKEN' } })
}
try {
const payload = verifyAccess(auth.slice(7))
req.user = { id: payload.sub, role: payload.role }
next()
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: { code: 'TOKEN_EXPIRED' } })
}
return res.status(401).json({ error: { code: 'INVALID_TOKEN' } })
}
}
// Dùng:
app.get('/me', requireAuth, (req, res) => {
res.json({ user_id: req.user.id })
})
Refresh token rotation — bắt buộc
Mỗi lần dùng refresh token, server cấp refresh mới và invalidate cái cũ. Nếu attacker dùng refresh token cũ → server phát hiện re-use → revoke cả family.
app.post('/auth/refresh', async (req, res) => {
const rt = req.cookies.rt
if (!rt) return res.status(401).json({ error: { code: 'NO_REFRESH' } })
let payload
try {
payload = jwt.verify(rt, REFRESH_SECRET, { issuer: 'alodev.vn' })
} catch {
return res.status(401).json({ error: { code: 'INVALID_REFRESH' } })
}
const stored = await db.refreshTokens.findByJti(payload.jti)
if (!stored || stored.revoked_at) {
// Reuse detection — revoke cả family
await db.refreshTokens.revokeAllForUser(payload.sub)
return res.status(401).json({ error: { code: 'REFRESH_REUSE_DETECTED' } })
}
// Rotate: revoke cái cũ, cấp cái mới
await db.refreshTokens.revoke(payload.jti)
const newJti = randomUUID()
await db.refreshTokens.insert({
jti: newJti, user_id: payload.sub,
expires_at: new Date(Date.now() + 30 * 86400_000),
})
const newRt = signRefreshToken(payload.sub, newJti)
res.cookie('rt', newRt, { httpOnly: true, secure: true, sameSite: 'strict', path: '/auth', maxAge: 30 * 86400_000 })
const user = await db.users.findById(payload.sub)
res.json({ access_token: signAccessToken(user.id, user.role) })
})
Logout: revoke refresh token
app.post('/auth/logout', async (req, res) => {
const rt = req.cookies.rt
if (rt) {
try {
const payload = jwt.verify(rt, REFRESH_SECRET)
await db.refreshTokens.revoke(payload.jti)
} catch {}
}
res.clearCookie('rt', { path: '/auth' })
res.status(204).end()
})
Access token vẫn còn sống ≤15 phút sau logout. Nếu bài toán cần revoke ngay (fraud, account compromise), thêm blacklist Redis với TTL = remaining lifetime.
HS256 vs RS256
HS256 dùng 1 secret cho cả sign và verify. Service nào cần verify cũng cần secret → secret leak ở 1 service = mọi service bị compromise.
RS256 (RSA) tách: private key sign (chỉ auth service giữ), public key verify (mọi service đều có). An toàn hơn cho microservices.
// RS256 setup
import { readFileSync } from 'node:fs'
const privateKey = readFileSync('./keys/jwt-private.pem')
const publicKey = readFileSync('./keys/jwt-public.pem')
const token = jwt.sign({ sub: userId }, privateKey, {
algorithm: 'RS256', expiresIn: '15m'
})
const decoded = jwt.verify(token, publicKey, { algorithms: ['RS256'] })
Luôn pass algorithms: ['RS256'] khi verify. Nếu không, attacker có thể gửi token với alg: none hoặc downgrade attack.
5 pitfall bảo mật JWT phổ biến
- Lưu trong localStorage: bị XSS lấy → dùng httpOnly cookie cho refresh, memory cho access
- Không verify
alg: attacker đổi sangnone→ server skip verify. Luôn whitelist algorithms. - Secret yếu: dùng < 32 char hoặc dictionary word → brute-force được. Sinh bằng
openssl rand -base64 64. - TTL dài cho access token: token leak = thiệt hại 24h+. Giữ ≤ 15 phút.
- Không rotate refresh: refresh token leak = attacker dùng vĩnh viễn. Bắt buộc rotate.
Checklist JWT production
- ✅ Access TTL ≤ 15 phút, refresh TTL ≤ 30 ngày
- ✅ Refresh token rotation + reuse detection
- ✅ httpOnly + Secure + SameSite=Strict cookie cho refresh
- ✅ Whitelist algorithm khi verify
- ✅ Secret ≥ 32 char, sinh ngẫu nhiên
- ✅
issclaim verified để chống token từ system khác - ✅ Blacklist Redis cho revoke ngay (nếu bài toán cần)
Kết luận
JWT authentication mạnh khi implement đúng — yếu khi sai. Đừng dùng JWT chỉ vì hype. Nếu app anh là monolith với 1 client web, server-side session + Redis có thể đơn giản và an toàn hơn. Đọc thêm OAuth 2.0 để hiểu khi nào cần delegate auth, và API security checklist để hardening toàn diện.