共计 3886 个字符,预计需要花费 10 分钟才能阅读完成。
为什么需要 access token 与 refresh token
在 Web 应用中,安全认证是保护用户数据的第一道防线。access token 作为短期令牌(通常 15-30 分钟有效期)的设计主要基于以下考虑:

- 最小化风险暴露窗口 :即使 token 被截获,攻击者能操作的时间也有限
- 符合安全最佳实践 :OAuth 2.0 规范建议使用短期 access token
- 平衡用户体验 :过长的有效期会增加安全隐患,过短会导致频繁登录
refresh token 的引入完美解决了这个矛盾点——它作为长期凭证(通常 7 -30 天)存放在服务端,仅用于获取新的 access token。这种机制带来了三个核心优势:
- 用户无需重复输入凭据
- 可以主动撤销特定设备的访问权限
- 细粒度的会话控制能力
存储方案深度对比
选择合适的 token 存储方式直接影响系统安全性:
LocalStorage
- 优点:
- 容量大(5MB+)
- 同源策略保护
- 缺点:
- 易受 XSS 攻击
- 需手动实现 CSRF 防护
SessionStorage
- 优点:
- 标签页独立隔离
- 会话结束自动清除
- 缺点:
- 刷新页面不会清除
- 同样存在 XSS 风险
HttpOnly Cookie
- 优点:
- 免疫 XSS 攻击
- 自动携带在请求中
- 缺点:
- 需防范 CSRF
- 有 4KB 大小限制
生产建议 :access token 存 HttpOnly Cookie,refresh token 只出现在服务端通信中。
Node.js 完整实现
JWT 签发与验证
// auth/jwt.js
const jwt = require('jsonwebtoken');
const SECRET = process.env.JWT_SECRET;
const ACCESS_EXPIRES = '15m';
const REFRESH_EXPIRES = '7d';
class JWTService {static generateAccessToken(payload) {
return jwt.sign(payload, SECRET, {expiresIn: ACCESS_EXPIRES});
}
static generateRefreshToken(payload) {
return jwt.sign(payload, SECRET, {
expiresIn: REFRESH_EXPIRES,
jwtid: crypto.randomUUID() // 唯一标识});
}
static verifyToken(token) {
try {return jwt.verify(token, SECRET);
} catch (err) {throw new Error('Invalid token');
}
}
}
Redis 存储设计
// auth/tokenStore.js
const redis = require('redis');
const client = redis.createClient({url: process.env.REDIS_URL});
class TokenStore {async saveRefreshToken(userId, tokenId, deviceHash) {
await client.hSet(`user:${userId}:refresh`,
tokenId,
JSON.stringify({
device: deviceHash,
expires: Date.now() + 7 * 24 * 60 * 60 * 1000})
);
}
async revokeToken(userId, tokenId) {await client.hDel(`user:${userId}:refresh`, tokenId);
}
async verifyToken(userId, tokenId, deviceHash) {
const tokenData = await client.hGet(`user:${userId}:refresh`,
tokenId
);
if (!tokenData) return false;
const {device, expires} = JSON.parse(tokenData);
return device === deviceHash && Date.now() < expires;}
}
竞态处理中间件
// middlewares/auth.js
const refreshLocks = new Map();
async function refreshTokenLock(userId) {while (refreshLocks.has(userId)) {await new Promise(resolve => setTimeout(resolve, 50));
}
refreshLocks.set(userId, true);
return () => refreshLocks.delete(userId);
}
async function authMiddleware(req, res, next) {
try {
const accessToken = req.cookies.access_token;
const decoded = JWTService.verifyToken(accessToken);
req.user = decoded;
return next();} catch (err) {if (err.message !== 'Invalid token') return next(err);
const refreshToken = req.cookies.refresh_token;
if (!refreshToken) return res.sendStatus(401);
try {const decoded = JWTService.verifyToken(refreshToken);
const unlock = await refreshTokenLock(decoded.sub);
// 验证 refresh token 有效性
const isValid = await tokenStore.verifyToken(
decoded.sub,
decoded.jti,
req.deviceHash
);
if (!isValid) {unlock();
return res.clearCookie('access_token')
.clearCookie('refresh_token')
.sendStatus(401);
}
// 签发新 token
const newAccess = JWTService.generateAccessToken({
sub: decoded.sub,
role: decoded.role
});
res.cookie('access_token', newAccess, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production'
});
req.user = JWTService.verifyToken(newAccess);
unlock();
return next();} finally {unlock?.();
}
}
}
高级安全策略
设备指纹绑定
// utils/device.js
function generateDeviceHash(req) {
const fingerprint = [req.headers['user-agent'],
req.ip,
req.headers['accept-language']
].join('|');
return crypto
.createHash('sha256')
.update(fingerprint)
.digest('hex');
}
黑名单机制
// auth/blacklist.js
class TokenBlacklist {async revoke(token) {const decoded = jwt.decode(token);
await client.set(`blacklist:${decoded.jti}`,
'1',
'PX',
decoded.exp * 1000 - Date.now());
}
async isRevoked(jti) {return Boolean(await client.exists(`blacklist:${jti}`));
}
}
分布式同步
// auth/sync.js
const pubClient = redis.createClient({url: process.env.REDIS_PUBSUB_URL});
const subClient = pubClient.duplicate();
subClient.subscribe('token_revoked', (jti) => {blacklist.revokeLocally(jti);
});
function publishRevocation(jti) {pubClient.publish('token_revoked', jti);
}
生产环境避坑指南
- 循环刷新防护 :
- 在 refresh token 请求中检查调用栈深度
-
设置最大重试次数(推荐 3 次)
-
多端会话管理 :
- 每个设备分配独立的 refresh token
-
实现管理接口查看 / 终止特定会话
-
监控指标设计 :
- token 刷新成功率
- 异常设备的地理分布
- 单用户高频刷新告警
开放性问题
- Serverless 架构中,如何避免频繁的 Redis 连接开销?
- 考虑使用云厂商的托管令牌服务
-
尝试无状态的黑名单验证算法
-
短期证书替代方案是否可行?
- mTLS 确实能解决部分问题
- 但会带来证书管理复杂性
- 移动端支持成本较高
这套方案已在日活百万级的应用中稳定运行,核心在于理解:安全不是绝对的,而是在用户体验与风险控制间找到最佳平衡点。根据你们的业务特点,可能需要调整 token 有效期、存储策略等参数。
正文完
