JWT 双 Token 机制:Access Token + Refresh Token 实战
2026 年 1 月 · 阅读约 12 分钟 · JWT 系列第 2 篇
上一篇 《JWT 原理与结构》 我们讲了 JWT 是什么、怎么签名验证。但在生产环境,单 Token 几乎注定出问题。本篇讲生产级方案——Access Token + Refresh Token 双 Token 机制,重点解决"怎么安全撤销"和"怎么不打扰用户"这对矛盾。
单 Token 困境:为什么需要双 Token?
JWT 一旦签发就无法主动失效(服务端默认不存任何状态),所以你必须靠 exp 过期。问题是:
- 短 exp(15 分钟):用户每 15 分钟被迫重新登录,体验极差,尤其在填长表单时突然掉线
- 长 exp(30 天):token 泄露后 30 天内都有效,无法撤销,被盗风险巨大
解法就是双 Token 分工:一个短命负责日常请求(被盗也很快失效),一个长命只负责换短命的(暴露面最小)。
双 Token 机制详解
| 特性 | Access Token | Refresh Token |
|---|---|---|
| 有效期 | 5–30 分钟 | 7–30 天 |
| 用途 | 调用 API | 只用于换新 access |
| 传输 | Authorization: Bearer header | httpOnly Cookie |
| 存储 | 内存(最佳) / sessionStorage | httpOnly + Secure + SameSite cookie |
⚠️ 严禁把任何 token 存 localStorage——XSS 攻击可读。
完整登录流程
1. POST /api/login {email, password}
→ Server 验证凭据
→ 返回 access (15min) + Set-Cookie refresh (7天)
2. GET /api/profile Header: Authorization: Bearer <access>
→ Server 验签 → 返回用户数据
3. (15min 后) access 过期
→ GET /api/refresh 浏览器自动带 refresh cookie
→ Server 验证 → 返回新 access + 新 refresh (rotation)
→ 旧 refresh 立即失效(防重放)
4. (7天后) refresh 也过期 → 跳登录页
Node.js 完整实现
登录端点
// login.js
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const ACCESS_SECRET = process.env.ACCESS_SECRET;
const REFRESH_SECRET = process.env.REFRESH_SECRET;
app.post('/api/login', async (req, res) => {
const user = await User.findOne({ email: req.body.email });
if (!user || !await bcrypt.compare(req.body.password, user.hash))
return res.status(401).json({ error: 'Invalid credentials' });
const access = jwt.sign(
{ sub: user._id, role: user.role },
ACCESS_SECRET,
{ expiresIn: '15m' }
);
const refresh = jwt.sign(
{ sub: user._id },
REFRESH_SECRET,
{ expiresIn: '7d' }
);
res.cookie('refresh', refresh, {
httpOnly: true, // JS 读不到,防 XSS
secure: true, // 仅 HTTPS
sameSite: 'strict', // 防 CSRF
maxAge: 7 * 24 * 60 * 60 * 1000
});
res.json({ access, user: { id: user._id, name: user.name } });
});
Refresh Token Rotation(防重放核心)
定义:每次用 refresh 换新 access 时,同时换新 refresh,旧 refresh 立即失效。
为什么能防重放:攻击者截获旧 refresh 后想重放,服务端发现这个 token 已在使用过集合里 → 立即撤销整条 token 链,强制全员重新登录。
const usedRefreshTokens = new Set(); // 生产用 Redis
app.post('/api/refresh', async (req, res) => {
const oldRefresh = req.cookies.refresh;
if (!oldRefresh) return res.status(401).end();
// 检测重放:旧 token 被用过 → 整链撤销
if (usedRefreshTokens.has(oldRefresh)) {
usedRefreshTokens.clear(); // 撤销所有
return res.status(401).json({ error: 'Token reuse detected' });
}
try {
const payload = jwt.verify(oldRefresh, REFRESH_SECRET);
usedRefreshTokens.add(oldRefresh);
const newAccess = jwt.sign(
{ sub: payload.sub },
ACCESS_SECRET,
{ expiresIn: '15m' }
);
const newRefresh = jwt.sign(
{ sub: payload.sub },
REFRESH_SECRET,
{ expiresIn: '7d' }
);
res.cookie('refresh', newRefresh, {
httpOnly: true, secure: true, sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000
});
res.json({ access: newAccess });
} catch (err) {
res.status(401).end();
}
});
4 种 JWT 撤销机制对比
| 方案 | 原理 | 评价 |
|---|---|---|
| 黑名单 | Redis 存已撤销 jti,请求时检查 | 简单,需共享存储 |
| 白名单 | 默认拒绝,只放白名单内 token | 极少用 |
| 短 exp + refresh | access 自动过期,refresh 可撤销 | 最推荐 |
| tokenVersion | 用户表加版本字段,+1 使旧 token 全失效 | 粒度粗,适合"踢出全部设备" |
jti + 黑名单实战
const crypto = require('crypto');
const Redis = require('ioredis');
const redis = new Redis();
// 签发时加 jti
const access = jwt.sign(
{ sub: user._id },
ACCESS_SECRET,
{ expiresIn: '15m', jwtid: crypto.randomUUID() }
);
// 主动撤销(登出/改密)
app.post('/api/logout', async (req, res) => {
const jti = req.user.jti;
await redis.set(`revoked:${jti}`, '1', 'EX', 900); // 15min 后自动清理
res.clearCookie('refresh');
res.status(204).end();
});
// 鉴权中间件
async function authMiddleware(req, res, next) {
const token = req.headers.authorization?.slice(7);
if (!token) return res.status(401).end();
const payload = jwt.verify(token, ACCESS_SECRET);
if (await redis.get(`revoked:${payload.jti}`))
return res.status(401).json({ error: 'Token revoked' });
req.user = payload;
next();
}
5 个生产陷阱(真实踩坑)
- Access 存 localStorage → XSS 一锅端。改用内存 + refresh cookie
- Access 放 Cookie → CSRF 风险。用 Authorization header
- Refresh 存 localStorage → 同 1
- 不验
alg字段 →alg=none绕过 / RS256 公钥被当 HS256 secret - 同一 secret 签 access 和 refresh → 一个泄露 = 全完。必须用两个不同 secret
- 不做 rotation → refresh 永久有效,被盗不可控
🛠️ 动手试一下
用 DevToolbox JWT 解码工具 解码一个现有的 JWT,看看 payload 里 sub、exp、jti 这些字段。
常见问题
Q1:为什么 access 短、refresh 长?
A:安全 vs 体验的权衡。Access 暴露面最大(每个 API 请求都带),所以必须短命;Refresh 只在换 token 时用,暴露面小,可以长命。
Q2:Refresh 也存 Redis 不行吗?
A:可以(白名单模式),但需要持久化和一致性保障。配合 rotation + 短 refresh(7天)已足够,大多数场景不需要。
Q3:前端如何自动刷新?
A:axios 拦截器:收到 401 → 调 /api/refresh → 拿到新 access → 重发原请求。多个并发 401 用队列避免重复刷新。
Q4:Access 还在用时被撤销怎么办?
A:后端 jti 黑名单 + 前端拦截器收到 401 → 自动跳登录页。最长延迟 = access 剩余有效期(15 分钟)。
Q5:如何防止 refresh 端点被滥用?
A:三层防护:① 限流(每 IP 每分钟 5 次)② IP + User-Agent 绑定 ③ 设备指纹。配合 rotation,单 refresh 重放立刻触发整链撤销。
总结
JWT 双 Token 机制的核心思想是职责分离:Access 负责日常访问(短命可丢),Refresh 负责续命(长命但严管)。配合 Rotation(防重放)、httpOnly Cookie(防 XSS)、jti 黑名单(主动撤销),就构成生产级方案。
下篇我们会讲 OAuth 2.0 + JWT 集成——第三方登录场景下 JWT 怎么用。
📚 系列阅读
- 《JWT 原理与结构:Header / Payload / Signature 详解》(前置阅读)
- 《Base64 编码完全指南》(JWT 用 Base64URL)