Bỏ qua menu, vào nội dung chính

Xác thực JWT trong Node.js: đăng nhập, refresh token, đăng xuất

JWT authentication thực chiến trong Node.js: sign, verify, refresh token, blacklist, rotation và các pitfall bảo mật cần tránh. Code chạy được ngay.

4 phút đọc

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.

Trang giới thiệu JSON Web Token trên jwt.io
Trang giới thiệu JSON Web Token trên jwt.io (jwt.io), ảnh chụp ngày 27/09/2026.

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.

texteyJhbGciOiJIUzI1NiJ9.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

bashnpm install jsonwebtoken cookie-parser bcrypt

Tạo cấu trúc tối thiểu:

javascript// 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 đủ

javascriptimport 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

javascriptexport 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.

javascriptapp.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

javascriptapp.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.

javascript// 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 sang none -> 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
  • iss claim 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.

  • JWT
  • Node.js

Nhận bài mới

Để lại email để nhận bài mới của series Kiến thức lập trình, hoặc theo dõi bằng RSS.

RSS Kiến thức lập trình

Email chỉ dùng để gửi bài mới, xem Chính sách bảo mật.

Trao đổi về dự án

Cần tư vấn?