共计 4618 个字符,预计需要花费 12 分钟才能阅读完成。
背景痛点:为什么需要双令牌机制?
在传统的 Web 应用中,Session-Cookie 方案曾是主流选择。但随着前后端分离架构和移动应用的普及,这种方案暴露出几个关键问题:

- 服务器需要维护会话状态,不利于水平扩展
- CORS 场景下 Cookie 处理复杂
- 移动端 App 难以原生支持 Cookie 机制
JWT(JSON Web Token)的出现解决了无状态认证的问题,但单纯使用 access token 会带来新的安全隐患:
- 长期有效的 token 一旦泄露,攻击者可以永久冒充用户
- 缩短 token 有效期会导致用户体验下降(频繁重新登录)
- 客户端存储 token 容易受到 XSS 攻击
这就是引入 refresh token 的初衷——通过『短期 access token+ 长期 refresh token』的组合,在安全性和用户体验之间取得平衡。
技术方案对比
Session vs JWT 双令牌
| 维度 | Session 方案 | JWT 双令牌方案 |
|---|---|---|
| 服务端状态 | 需要维护 | 完全无状态 |
| 跨域支持 | 需要额外配置 | 原生支持良好 |
| 移动端适配 | 较差 | 友好 |
| 失效即时性 | 立即生效 | 依赖 token 过期时间 |
| 实现复杂度 | 较低 | 中高等 |
Refresh Token 工作流程
- 用户使用凭证(如密码)获取初始的 access/refresh token 对
- access token 过期后,客户端使用 refresh token 获取新的 access token
- refresh token 通常有较长的有效期(如 7 天),但每次使用后会被替换(rotation 机制)
- 如果 refresh token 也过期,则需要重新登录
Node.js 核心实现
基础依赖安装
npm install express jsonwebtoken redis cookie-parser
JWT 签发与验证(HS256)
// utils/jwt.js
const jwt = require('jsonwebtoken');
const SECRET = 'your-256-bit-secret';
// 签发 access token (15 分钟有效期)
function issueAccessToken(userId) {
return jwt.sign({ sub: userId},
SECRET,
{expiresIn: '15m'}
);
}
// 签发 refresh token (7 天有效期)
function issueRefreshToken(userId) {
return jwt.sign({ sub: userId, type: 'refresh'},
SECRET,
{expiresIn: '7d'}
);
}
// 验证 token 有效性
function verifyToken(token) {
try {return jwt.verify(token, SECRET);
} catch (err) {return null;}
}
Redis 存储模块
// store/redis.js
const redis = require('redis');
const client = redis.createClient();
// 存储 refresh token
async function storeRefreshToken(userId, token) {await client.set(`refresh:${userId}`, token, {EX: 60 * 60 * 24 * 7 // 7 天过期});
}
// 验证 refresh token 是否匹配
async function verifyRefreshToken(userId, token) {const storedToken = await client.get(`refresh:${userId}`);
return storedToken === token;
}
// 吊销 refresh token
async function revokeRefreshToken(userId) {await client.del(`refresh:${userId}`);
}
Express 路由实现
// routes/auth.js
const express = require('express');
const router = express.Router();
const {
issueAccessToken,
issueRefreshToken,
verifyToken
} = require('../utils/jwt');
const {
storeRefreshToken,
verifyRefreshToken
} = require('../store/redis');
// 登录接口
router.post('/login', async (req, res) => {const { username, password} = req.body;
// 1. 验证用户凭证(示例代码,实际应查数据库)const userId = authenticate(username, password);
// 2. 签发双令牌
const accessToken = issueAccessToken(userId);
const refreshToken = issueRefreshToken(userId);
// 3. 存储 refresh token
await storeRefreshToken(userId, refreshToken);
// 4. 返回响应
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production'
});
res.json({accessToken});
});
// 令牌刷新接口
router.post('/refresh', async (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) return res.sendStatus(401);
// 1. 验证 refresh token 有效性
const payload = verifyToken(refreshToken);
if (!payload || payload.type !== 'refresh') {return res.sendStatus(403);
}
// 2. 检查是否与存储的 token 匹配
const isValid = await verifyRefreshToken(payload.sub, refreshToken);
if (!isValid) return res.sendStatus(403);
// 3. 吊销旧 refresh token
await revokeRefreshToken(payload.sub);
// 4. 签发新令牌
const newAccessToken = issueAccessToken(payload.sub);
const newRefreshToken = issueRefreshToken(payload.sub);
// 5. 存储新 refresh token
await storeRefreshToken(payload.sub, newRefreshToken);
// 6. 返回响应
res.cookie('refreshToken', newRefreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production'
});
res.json({accessToken: newAccessToken});
});
安全最佳实践
存储策略
- access token:存储在内存或临时变量中,不要持久化
- refresh token:必须使用 HttpOnly+Secure Cookie,防止 XSS 攻击
有效期设置
- access token:建议 15-30 分钟
- refresh token:建议 7 天,并实现单次使用机制
防御 CSRF
- 为敏感操作添加 CSRF Token
- 检查 Origin/Referer 头部
- 实现 SameSite Cookie 策略
// 启用 SameSite Strict 模式
app.use(cookieParser({
sameSite: 'strict',
secure: process.env.NODE_ENV === 'production'
}));
常见问题解决方案
并发刷新请求
当多个请求同时检测到 access token 过期时,可能触发多次 refresh 请求。解决方案:
- 在 Redis 中设置刷新锁(mutex)
- 使用内存标记记录正在刷新的状态
// utils/mutex.js
const mutex = new Map();
async function acquireLock(userId) {if (mutex.has(userId)) return false;
mutex.set(userId, true);
return true;
}
function releaseLock(userId) {mutex.delete(userId);
}
令牌吊销
当用户主动登出或检测到异常时,需要立即失效令牌:
- 维护 access token 黑名单(适用于 JWT 短期有效场景)
- 立即删除 refresh token
// 登出接口
router.post('/logout', async (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) return res.sendStatus(204);
// 解析 payload 获取 userId
const payload = verifyToken(refreshToken);
if (payload && payload.sub) {await revokeRefreshToken(payload.sub);
}
// 清除 cookie
res.clearCookie('refreshToken');
res.sendStatus(204);
});
动手实验
使用 Postman 测试流程
- 配置环境变量:
baseUrl: 你的 API 基础地址username: 测试用户名-
password: 测试密码 -
创建测试集合:
- 登录请求 :POST
{{baseUrl}}/auth/login{"username": "{{username}}", "password": "{{password}}" } - 受保护接口 :GET
{{baseUrl}}/protected
添加 Header:Authorization: Bearer {{accessToken}} -
刷新令牌 :POST
{{baseUrl}}/auth/refresh -
测试场景:
- 正常登录后访问受保护接口
- 等待 15 分钟后观察 access token 过期
- 自动触发刷新流程
- 尝试使用旧的 refresh token(应返回 403)
安全测试
- XSS 模拟:尝试通过控制台读取
document.cookie(应看不到 refresh token) - CSRF 模拟:从一个外部域名发起刷新请求(应被 SameSite 策略阻止)
- 令牌泄露测试:使用已撤销的 refresh token 尝试刷新(应失败)
总结
通过本文的实践,我们实现了:
- 安全的双令牌认证流程
- 自动续期机制保障用户体验
- 多层防御对抗常见攻击手段
实际生产环境中,还需要考虑:
- 密钥轮换策略
- 令牌绑定(Token Binding)增强安全性
- 监控和异常检测机制
这套方案已在我们的多个生产项目中稳定运行,有效平衡了安全性和开发体验。希望这篇指南能帮助你在自己的项目中实现可靠的认证系统。
正文完
