在互联网服务的开发里,JWT(JSON Web Token)几乎是绕不开的话题。它让接口无状态化,让服务器不用记着每个用户是谁,只要用户拿来一个令牌,拆开验一验就知道身份了。听着很爽,可真正用起来之后,很多人会发现一个问题:令牌一旦发出去,服务器就管不着它了。用户点了退出登录,令牌还是能正常用;密码改了,旧令牌照样能通过校验;想让某个设备强制下线,根本无能为力。这些问题,全是因为我们选择了无状态,而会话管理的本质恰恰是需要状态的。这篇文章就聊聊这个矛盾,看看在不用把整个会话都塞进数据库的前提下,怎么让令牌更听话。
一、JWT带来的舒适与麻烦
1.1 无状态到底有多舒服
传统的会话方式,是用户登录成功后,服务器在内存或数据库里存一个 sessionId,然后把 sessionId 种到用户浏览器的 Cookie 里。用户每次请求都带着这个 ID,服务器拿 ID 去查会话数据,查到就放行,查不到就拦下。这套方案很成熟,但有一个明显的毛病:服务器必须保存会话数据。如果服务是多节点部署的,服务器 A 发了 session,下一次请求被负载均衡转到服务器 B,B 那里没有这份数据,用户就会被判定为未登录。为了解决这个问题,要么做 session 粘滞,让同一个用户总是访问同一台机器;要么把 session 数据抽出来放到外部的 Redis 里,统一读写。前者让运维头疼,后者让架构多了一个强依赖。
JWT 给出的路子完全不同。用户登录成功之后,服务器生成一个带有签名和过期时间的字符串。这个字符串里直接包含用户 ID、角色之类的信息。服务器不保存它,谁来请求,只要验签通过、没过期,就认。这意味着随便加多少台服务器,都天然共享会话状态,因为状态本身就在客户端手里。扩容轻轻松松,服务器重启也不会丢用户登录信息。这个特性,让很多微服务项目毫不犹豫地选 JWT。
1.2 令牌一旦发出,就变成了脱缰的野马
舒服是要付出代价的。JWT 最大的问题,就是不好吊销。假设用户点了退出登录,期望的效果是,这个登录态立刻失效。可是从 JWT 的机制上看,令牌还揣在用户浏览器里,并且还没过期。服务器如果没有额外的记录,下一次用户带着这个令牌来,校验签名和过期时间依然能通过,退出登录就形同虚设。
再比如,管理员把某个用户封禁了,或者用户自己修改了密码,结果用户那边还留着旧令牌,依然可以继续访问接口。这不是 JWT 本身的设计缺陷,而是任何无状态认证都要面对的现状。令牌本身是自包含的,服务器没有痕迹可查,自然想管也管不了。
还有过期时间的设定。过期时间设短一点,比如 15 分钟,那令牌泄露后被冒用的时间窗口就很小,安全一些。可是用户会一直骂娘,因为每 15 分钟就要重新登录一次,体验极差。把过期时间设长一点,比如 7 天,用户舒服了,可一旦令牌泄露,攻击者就能在 7 天之内畅通无阻。既要保证体验,又要保证安全,这个跷跷板很难摆平。
二、常见的补救办法
既然纯 JWT 管不住会话,人们自然就想办法在无状态的基础上加一些“有状态”的辅助手段。下面这几个方案,是最常见的思路。
2.1 黑名单:记住谁不能用了
黑名单的思路很直接:把要吊销的令牌的 jti(JWT ID)或者某个唯一标识,存到一个共享的存储里,比如 Redis。用户在退出登录、修改密码、被强制下线的时候,服务器把这个令牌的标识加入黑名单,并设置一个和令牌剩余有效期一致的过期时间。以后每次请求,验证完 JWT 的签名和过期时间后,再查一下这个标识是不是在黑名单里,如果在,就拒绝访问。
这个方案能解决问题,但有个尴尬的地方:如果黑名单太多,Redis 的存储成本就上来了。假设一个系统一天有几十万用户退出登录,每个令牌的标识加上过期时间,可能占几十字节,一天下来就是几十万条记录。好在 Redis 有天然的过期机制,能自动清理,所以实际压力可控。但要注意,每次请求都多一次 Redis 查询,Redis 一旦抖动,整个认证链路都会受影响。
2.2 白名单:干脆回到有状态
白名单的逻辑反着来:JWT 只管加密签名,不负责存储状态。服务器在签发令牌的时候,把令牌的 jti 存到 Redis,并关联用户 ID 和过期时间。请求过来时,不光验签,还要检查这个 jti 是不是还在白名单里。如果在,就放行;如果被删除了,就拒绝。
这种方案的优势是吊销非常精准,删掉某个 jti 就能让那个令牌立刻失效。缺点也明显:JWT 的“无状态”红利几乎被吃掉了。因为你每次请求都查一次 Redis,相当于服务器还是得记住所有有效令牌。这就是从无状态滑回了有状态。虽然比传统 session 更灵活一些,但部署和运维成本并没有比 session + Redis 少太多。不过有些系统为了能随时踢人下线,还是愿意这么干。
2.3 缩短有效期:最朴素的力学拉扯
还有一种完全不依赖额外存储的办法,就是把令牌的过期时间设得很短。短到哪怕令牌泄露,攻击者能用的时间也极其有限。比如 5 分钟过期,用户退出登录后,最多 5 分钟内还有可能被冒用,5 分钟一过,令牌自然失效,问题也就不存在了。
但是用户体验会被按在地上摩擦。没有人愿意每 5 分钟登录一次。所以这个方案通常跟刷新令牌配合。用户登录拿到一个普通的短期访问令牌,外加一个长期有效的刷新令牌。访问令牌过期后,用户拿着刷新令牌去换一个新访问令牌。这样用户不会频繁登录,又因为访问令牌寿命很短,安全窗口被压缩得比较小。刷新令牌一般存服务器,或者做成可吊销的,这样如果用户退出,服务器把刷新令牌删掉,旧的访问令牌等它自己过期就完事了。
三、平衡令牌有效性与服务端存储成本
上面这些方案,没有绝对的好,只有适不适合。核心矛盾是:令牌有效期越长,越方便,但越不安全;要缩短有效期,就得靠刷新机制或者额外存储,存储成本又会上来。换句话说,我们一边想让服务器少记点东西,一边又想拥有随时能管的权力,这个平衡点需要结合业务场景去找。
3.1 先弄清楚哪些接口真的需要无状态
并不是所有接口都适合走 JWT。如果是像用户登录后的个人资料、下单、支付这些真正需要会话的接口,通常需要能即时吊销,所以把状态存一份是值得的。如果是给第三方开放 API,比如合作伙伴的客户端要调用你的数据接口,它们用的是长期 API Key,走 JWT 无状态签名反而合适,因为这类凭据本来就很稳定,也不存在“用户退出登录”这种操作。所以先分清场景,再决定要不要为了 JWT 的无状态牺牲可管理性。
3.2 设计一种分层的过期策略
假设你最终决定用 JWT,并且想要兼顾安全和成本,可以这样设计:
- 短期访问令牌:有效期 15 分钟到 2 小时,只用来访问业务接口。
- 长期刷新令牌:有效期几天到几周,存到服务器,或者做成一个随机字符串保存在 Redis 里。
- 退出登录时,只需要删除刷新令牌,访问令牌虽然还活着,但最多能活到它的过期时间,影响范围很小。
- 敏感操作(比如修改密码、绑定手机号)要求用户重新登录或验证身份,避免旧令牌残留的危险。
这个分层模型,让存储成本不在于每个接口都要查,而在于每个活跃用户只需要多存一条刷新令牌记录。一个日活百万的系统,可能只需要存几十万条记录,压力远小于给每个请求做白名单校验。
3.3 用 Redis 来控制成本
Redis 是这类场景的绝配,因为它可以给每个 key 设置过期时间。比如把刷新令牌的 jti 当做 key,设置一个跟刷新令牌有效期相同的时间。用户退出登录时,把 key 删掉,刷新令牌立即失效。存储成本跟活跃会话数量成正比,不会随着时间无限膨胀。另外,还可以利用 Redis 的原子操作,实现单设备登录、多点互踢等策略。比如登录时把当前用户的旧 jti 替换掉,这样只保留最新一个会话。
3.4 什么时候可以大胆采用短期无状态
如果你的业务允许令牌在几分钟内“延迟失效”,那么完全可以把访问令牌有效期设成 5 分钟,不用任何黑名单。用户退出登录后,旧令牌最多还能用 5 分钟,体验上基本感知不到。很多手游、工具类应用就是这么干的。它们宁可让服务器多承担一些签发令牌的计算,也不愿意维护复杂的黑名单。
四、详细示例:用 Node.js 做一个兼顾成本与安全的会话管理
下面我们用 Node.js 作为技术栈,演示如何搭建一个 JWT 会话管理系统。为了贴近真实,我们会用到两个依赖:jsonwebtoken 用来签发和验证 JWT,redis 用来存刷新令牌和黑名单。为了保证示例可直接运行,我们先用内存 Map 模拟 Redis,并在代码里注释说明如何换成真正的 Redis。
// 技术栈:Node.js + jsonwebtoken + ioredis(示例中为演示方便,使用本地 Map 模拟)
const jwt = require('jsonwebtoken');
// 模拟 Redis 的存储对象
// 在实际项目中,建议改用 ioredis 连接真实 Redis
const tokenStore = new Map();
// 密钥,实际应放入环境变量
const ACCESS_SECRET = 'your-access-secret';
const REFRESH_SECRET = 'your-refresh-secret';
// 访问令牌有效期:15分钟
const ACCESS_EXPIRES_IN = '15m';
// 刷新令牌有效期:7天
const REFRESH_EXPIRES_IN = '7d';
/**
* 生成访问令牌
* @param {object} payload 要写入令牌的用户数据
* @returns {string} 访问令牌
*/
function generateAccessToken(payload) {
return jwt.sign(payload, ACCESS_SECRET, { expiresIn: ACCESS_EXPIRES_IN });
}
/**
* 生成刷新令牌,并保存到存储服务(这里用 Map 模拟 Redis)
* @param {string} userId 用户ID
* @returns {string} 刷新令牌
*/
function generateRefreshToken(userId) {
// 生成一个随机 jti,用于唯一标识这个刷新令牌
const jti = require('crypto').randomUUID();
// 签发的 token 本身也带上 jti,方便以后吊销
const refreshToken = jwt.sign({ userId, jti }, REFRESH_SECRET, {
expiresIn: REFRESH_EXPIRES_IN,
});
// 在存储中保存一条记录:key 是 jti,value 是 userId
// 真实 Redis 可以设置过期时间:client.set(jti, userId, 'EX', 7 * 24 * 3600);
tokenStore.set(jti, {
userId,
expiresAt: Date.now() + 7 * 24 * 3600 * 1000,
});
return refreshToken;
}
/**
* 刷新访问令牌
* 业务逻辑:拿到旧刷新令牌,验证合法性,删除旧记录,签发新令牌
* @param {string} refreshToken 刷新令牌
* @returns {object|null} 新的一组令牌,失败返回 null
*/
function refreshAccessToken(refreshToken) {
try {
// 验证刷新令牌的签名和有效期
const decoded = jwt.verify(refreshToken, REFRESH_SECRET);
// 检查这个刷新令牌是否还在存储中(即没有被吊销)
const record = tokenStore.get(decoded.jti);
if (!record || record.userId !== decoded.userId) {
// 找不到记录,说明已被删除,拒绝刷新
return null;
}
// 删除旧记录,防止同一个刷新令牌被重复使用
tokenStore.delete(decoded.jti);
// 签发新的访问令牌和新的刷新令牌
const newAccessToken = generateAccessToken({ userId: decoded.userId });
const newRefreshToken = generateRefreshToken(decoded.userId);
return {
accessToken: newAccessToken,
refreshToken: newRefreshToken,
};
} catch (error) {
// 签名验证失败或过期,返回 null
return null;
}
}
/**
* 退出登录
* 只需要把刷新令牌从存储中删除,访问令牌会自然过期
* @param {string} refreshToken 要吊销的刷新令牌
* @returns {boolean} 是否成功
*/
function logout(refreshToken) {
try {
const decoded = jwt.verify(refreshToken, REFRESH_SECRET);
// 删除对应记录,使该刷新令牌立即失效
const result = tokenStore.delete(decoded.jti);
return result;
} catch (error) {
return false;
}
}
// 模拟一个登录接口
function login(userId) {
// 假设密码已经验证通过
const accessToken = generateAccessToken({ userId });
const refreshToken = generateRefreshToken(userId);
return { accessToken, refreshToken };
}
// 模拟受保护接口的中间件
function authMiddleware(req, res, next) {
// 从请求头拿到 Bearer token
const authHeader = req.headers.authorization || '';
const token = authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ code: 401, message: '缺少令牌' });
}
try {
// 验证访问令牌,注意这里只需要签名和过期时间
// 不需要查询任何存储,因此是无状态的
const decoded = jwt.verify(token, ACCESS_SECRET);
req.userId = decoded.userId;
// 放行到后续处理函数
next();
} catch (error) {
// 令牌无效或过期
return res.status(401).json({ code: 401, message: '令牌无效或已过期' });
}
}
// 演示流程
function demo() {
console.log('用户登录,获取令牌');
const { accessToken, refreshToken } = login('user123');
console.log('访问令牌:', accessToken);
console.log('刷新令牌:', refreshToken);
console.log('模拟访问受保护接口(验证通过)');
// 这里用一个简单的对象来模拟 req/res
const fakeReq = {
headers: { authorization: `Bearer ${accessToken}` },
};
const fakeRes = {
status(code) {
console.log(`返回状态码:${code}`);
return this;
},
json(data) {
console.log('返回内容:', data);
},
};
authMiddleware(fakeReq, fakeRes, () => {
console.log('接口放行,当前用户是:', fakeReq.userId);
});
console.log('模拟退出登录');
const logoutResult = logout(refreshToken);
console.log('退出登录结果:', logoutResult);
console.log('尝试用旧刷新令牌换取新令牌');
const newTokens = refreshAccessToken(refreshToken);
console.log('刷新结果:', newTokens ? '成功' : '失败(已被吊销)');
}
// 执行演示
demo();
# 安装依赖(如果尚未安装)
npm install jsonwebtoken ioredis
4.1 代码里的核心设计
上面的示例里,你可能会注意到两个细节。第一,访问令牌完全没有查库,扩展性特别好。第二,刷新令牌每次刷新后,旧的立即作废,这样即使刷新令牌泄露,能被重放一次的机会也会被极大限制。这种设计叫“刷新令牌旋转”。很多公司会让刷新令牌在有效期之内都可以用,直到过期为止,但一旦出现安全问题,旋转机制能更快止损。
4.2 如果换成真实 Redis
把 Map 换成 Redis 非常简单。只需要安装 ioredis,然后项目启动时初始化一个 Redis 客户端,把 tokenStore.get 换成 redis.get,tokenStore.set 换成 redis.set,tokenStore.delete 换成 redis.del。记得给刷新令牌设置过期时间,让 Redis 自动清理。这样存储成本就天然受控了。
4.3 黑名单仍然是可选方案
有些场景下,我们并不希望一个访问令牌存活 15 分钟,可能希望用户点了“退出”立刻谁都进不来。这时可以额外维护一个黑名单,把当前访问令牌的 jti 写进 Redis,设置过期时间等于访问令牌剩余的有效时间。请求进来时,先验签,再查黑名单。这样做的代价是每次请求多一次 Redis 查询,但换来了更强的控制力。你也可以只对高风险操作做黑名单检查,普通浏览不查,成本就下来了。
五、注意事项
选型没有银弹,但有几个坑需要提前知道。
- 不要把敏感信息放进 JWT。因为 JWT 默认只做签名不做加密,任何拿到令牌的人都能解码看到 payload。所以里面不要放手机号、身份证号、密码等。
- 密钥要用环境变量管理,并且定期更换。一旦密钥泄露,所有令牌都能被伪造,后果非常严重。
- 刷新令牌存储在 Redis 时,最好把用户 ID 一起存进去。万一用户删号,可以批量删除该用户的所有刷新令牌。
- 访问令牌过期后不要立即让用户看到一个冰冷的“登录已过期”页面,而是前端检测到 401,自动拿刷新令牌去刷新。如果刷新也失败,才引导重新登录。
- 所有的存储操作都可能有延迟,检查“黑名单”或“白名单”时,要处理 Redis 异常。比如 Redis 挂掉了,是放行还是拒绝?放行更安全但可能放过已吊销的令牌,拒绝则可能导致整个系统不可用。一般建议在异常时暂不拒绝,让请求继续,然后告警给运维。
六、文章总结
JWT 的无状态优势与其会话管理能力天然不对等。想要让令牌可控制,就必须引入一定的服务端存储,变相回到有状态;想完全无状态,就得接受令牌在有效期内无法被吊销的代价。解决办法不是非黑即白,而是把令牌分层:用短期访问令牌承担无状态的性能优势,用可吊销的刷新令牌承担会话的持续性和安全性。再配合 Redis 的过期机制和刷新令牌旋转,就可以在令牌有效性与服务端存储成本之间找到一个让业务舒服的平衡点。在真实项目中,建议先梳理自己的安全要求和用户体验预期,再决定要付出多少存储成本。技术方案没有万能公式,只有符合当前业务的权衡。
评论
围绕“无状态架构下JWT的会话管理难题:平衡令牌有效性与服务端存储成本”参与讨论