Bỏ qua đến nội dung chính
JWTauthenticationNode.jssecuritybackend

JWT Authentication: Hướng Dẫn Implement Trong Node.js

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.

Xuất bản 10 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.

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

Zalo