做过后台接口鉴权的同学,基本都会撞上同一个尴尬情况:JWT签出去之后,用户明明点了退出登录,把旧Token拿到接口上再试一下,照样能请求成功。这不是JWT库的问题,而是它的无状态设计决定的。要给某个JWT“提前作废”,最直接、也最有操作性的一种办法,就是在后台维护一个黑名单。下面我就用自己在某后台管理系统里落地这套方案的实际过程,把思路、代码、踩坑和排查方法都串一遍,希望能帮到正在做登录态控制的后端同学。
1. 为什么JWT默认做不到“让旧Token失效”
1.1 JWT的无状态校验到底是什么意思
JWT通常由三部分组成:Header、Payload、Signature。服务器签发时用密钥对前两段签名,后面每次请求进来,服务器只需要验签,再检查exp过期时间就够了。它不像传统Session那样,在服务端存一份会话记录,所以服务器本身“不记得”自己发过哪些Token。
这里有个容易被忽略的关键点:验签通过不等于Token有效。验签只能证明Token没被篡改、确实是服务器签发的,但不能证明这个Token没有被用户主动废弃。就像一张盖了章的入场券,检票员只能确认章是真的、日期没过期,但没法知道这张票是不是已经被主人挂失。JWT默认就是这种不记名的模式,所以要让它“旧Token立即失效”,必须额外做一层状态记录,黑名单就是其中最常见的做法。
1.2 业务里必须让Token失效的三个典型场景
- 用户主动退出登录。这是最刚需的场景。用户点退出,前端把本地Token删掉,但如果服务端不做任何处理,攻击者只要提前拿到了这个Token,依然可以继续冒充用户。
- 修改密码或账号安全异常。用户改了密码后,理论上所有用旧密码时期签发的Token都应该失效,否则就会出现“密码改了,旧Token还能登录”的荒谬情况。
- 多端管理、踢人下线。比如后台管理员可以强制某个设备退出,或者用户自己在“我的登录设备”列表里删掉某台手机。这种情况下,目标是让某一个具体的Token失效,而不是把全部登录状态都干掉。
这三种场景有一个共同点:都希望“某张Token从现在开始别再用了”。而JWT默认只认签名和过期时间,所以必须引入黑名单机制。
1.3 不用黑名单的话,还有哪些替代方案
我在设计初期也对比过其他方案,不一定每个项目都适合上黑名单,先看一下取舍:
| 方案 | 核心思路 | 优点 | 局限 |
|---|---|---|---|
| 短过期 + RefreshToken | Access Token只有15分钟,过期后用RefreshToken续期 | 有效缩短“旧Token可用窗口” | 15分钟窗口内仍无法立即失效 |
| 改回服务端Session | 会话状态保存在服务端 | 踢人、退出、禁用都容易 | 又回到了服务端存储,失去JWT无状态优势 |
| 白名单 | 只保存当前有效的Token,校验时查是否存在 | 逻辑直接,天然能做踢人 | 每个有效请求都依赖存储,像Session但更分散 |
| 黑名单 | 保存已失效Token的标识,校验时查是否存在 | 只关心“作废的”,不增加正常Token的存储负担 | 多一次存储查询,需要维护过期清理 |
黑名单比较适合“大多数Token都正常使用、只有少数需要提前作废”的业务。如果系统里几乎每个Token都需要频繁失效,那不如直接上白名单。这一点想清楚,后面代码写起来才不会拧巴。
2. 黑名单方案的整体设计与存储选型
2.1 黑名单的核心思路
黑名单的逻辑可以浓缩成三步:
- 签发Token时,给每个Token分配一个唯一ID,一般叫
jti(JWT ID)。 - 需要提前失效时,把这个Token的
jti写进黑名单存储,并保留到该Token原本的过期时间。 - 每次请求进来,在JWT验签之后、业务逻辑之前,查一下这个
jti是否在黑名单里。如果在,直接返回401。
这个方案之所以好用,是因为它不和具体用户绑定,而是和“某一个Token”绑定。同一个用户登录了三个设备,拿到了三个不同的jti,只让其中一个失效,另外两个完全不受影响。
还需要注意:黑名单里存的是“Token标识”,不是“用户ID”。如果只存用户ID,那用户在一台设备退出,其他所有设备都会被误伤。我的经验是,但凡有人跟你说“用Redis存一个set,key是userId”,你就要警惕误伤问题。
2.2 黑名单里存什么:jti、哈希还是完整Token
这里有个很容易忽略的细节:到底拿什么当黑名单的Key。
- 如果发Token时带了
jti,直接用jti最方便。它是JWT标准字段,本身就是为了区分同一个签发者下不同Token存在的。 - 如果是老项目,以前发Token的时候没带
jti,新老Token混用,那也别急着改签发逻辑。可以用整个Token的哈希作为Key,比如SHA256之后再存,这样能兼容所有历史Token。 - 不建议直接把完整Token字符串当Redis Key。一是Key会很长,二是Token本身可能包含敏感信息,Redis日志、监控、慢查询里都会留下明文Token,等于裸奔。
用哈希做Key的时候,代码大概是这样的:
const crypto = require('crypto'); const tokenHash = crypto.createHash('sha256').update(token).digest('hex');然后在中间件里对拿到的Token算同样的哈希去查。这个办法对旧Token友好,缺点是每次都要算一次哈希,但成本可以忽略。
2.3 存储选型:Redis优先,内存兜底
黑名单这种“写一次、查多次、自动过期”的场景,Redis几乎是天然匹配的。主要理由有三个:
- 支持
EX过期时间,可以精确控制黑名单条目存活到Token过期那一刻。 - 多实例部署时是共享存储,不存在“用户在一台机器退出,另一台机器不知道”的问题。
- 本身内存操作,单次查询亚毫秒级,对接口耗时影响很小。
如果没有Redis环境,项目又是单机部署,临时用进程内Map也能演示:
const blacklist = new Map(); function addToBlacklist(jti, ttl) { blacklist.set(jti, Date.now() + ttl * 1000); setTimeout(() => blacklist.delete(jti), ttl * 1000); }但要清醒地看到,进程内Map在服务重启后全部丢失,多实例部署时更会出现“每台机器黑名单不一致”的问题。如果业务对安全有要求,生产环境请直接上Redis。
如果项目用的是关系型数据库,也可以建一张blacklist表,字段至少包含token_hash、expires_at,然后定期删过期数据。这个方案适合存储层不能引入Redis的团队,但查询性能比Redis差不少。
3. 后端代码实现:从发Token到拦截失效Token
下面用Node.js为例,把完整链路过一遍。用到的库是jsonwebtoken和ioredis,换到Java、Go、Python,思路完全一样。
3.1 登录时生成带jti的Token
登录成功之后,不只要返回Token,还要同时产生一个jti。这个jti是后续黑名单操作的基础。
const jwt = require('jsonwebtoken'); const { v4: uuidv4 } = require('uuid'); const SECRET_KEY = process.env.JWT_SECRET || 'please-change-me'; function issueToken(user) { const jti = uuidv4(); const token = jwt.sign( { userId: user.id, username: user.username }, SECRET_KEY, { expiresIn: '2h', jwtid: jti } ); return { token, jti, ttl: 2 * 60 * 60 }; }这里有几个细节你要注意:
jti用UUID生成,保证两次签发不会重复。expiresIn建议根据业务调节。后台管理系统给2小时,内部工具给更长,面向用户的App建议给短一点。- 密钥一定不要硬编码在代码里,放到环境变量或配置中心。
3.2 退出登录接口把jti写进黑名单
退出接口的前端逻辑是“删本地Token”,后端逻辑的核心则是“让这个Token从此刻开始失效”。
const redis = require('./redis'); async function logout(token) { let decoded; try { decoded = jwt.verify(token, SECRET_KEY); } catch (e) { // Token已经过期或非法,本身就是无效的,不需要再加黑名单 return; } const now = Math.floor(Date.now() / 1000); const ttl = decoded.exp - now; if (ttl > 0) { await redis.set(`jwt:blacklist:${decoded.jti}`, '1', 'EX', ttl); } }注意decoded.exp是Unix时间戳,单位是秒,而Date.now()返回的是毫秒,一定要除以1000再比较。我见过有人直接把这两个值相减,结果TTL变成负数,Redis直接报错。
如果遇到那种没有jti的历史Token,就把前面提到过的SHA256哈希作为Key即可:
const tokenHash = crypto.createHash('sha256').update(token).digest('hex'); await redis.set(`jwt:blacklist:${tokenHash}`, '1', 'EX', ttl);3.3 认证中间件统一校验
后端的登录校验一般都会收敛到一个中间件里。黑名单校验必须加在jwt.verify之后,因为要先拿到decoded.jti,同时也要先确认这个Token本身签名没被篡改。
const blacklistPrefix = 'jwt:blacklist:'; async function authRequired(req, res, next) { const header = req.headers.authorization || ''; const token = header.startsWith('Bearer ') ? header.slice(7) : null; if (!token) { return res.status(401).json({ message: '缺少Token' }); } let decoded; try { decoded = jwt.verify(token, SECRET_KEY); } catch (e) { return res.status(401).json({ message: 'Token无效或已过期' }); } const isBlacklisted = await redis.exists(blacklistPrefix + decoded.jti); if (isBlacklisted) { return res.status(401).json({ message: 'Token已失效,请重新登录' }); } req.user = decoded; next(); }这个中间件有几个关键点:
- 如果Token已经过期,
jwt.verify会直接抛异常,这时候根本不需要再查Redis。所以黑名单查询只对“签名有效且未过期”的Token有意义。 redis.exists返回1或0,不要直接写if (isBlacklisted)又反过来,注意判断。- 每次请求多一次Redis往返,对于后台系统完全可接受。如果接口QPS很高,可以在Redis前加一层本地缓存,把“最近被拉黑的jti”缓存几秒,但这个优化要谨慎,拉黑生效时间会变慢。
3.4 改密码、踢人下线的扩展处理
上面这套只能让“某一个Token”失效。如果业务要求“改了密码,所有设备全部下线”,黑名单逐条加是做不到的,你并不知道用户手里还有多少Token。这时候要引入“Token版本号”思路。
签发Token时,把用户的当前版本号写进Payload:
function issueToken(user) { const jti = uuidv4(); const token = jwt.sign( { userId: user.id, tokenVersion: user.tokenVersion }, SECRET_KEY, { expiresIn: '2h', jwtid: jti } ); return token; }每次请求时,除了查黑名单,再查Redis里这个用户当前的版本号,和Token里的tokenVersion比较:
const currentVersion = await redis.get(`user:tokenVersion:${decoded.userId}`); if (decoded.tokenVersion !== Number(currentVersion)) { return res.status(401).json({ message: '登录状态已失效,请重新登录' }); }修改密码或“踢出所有设备”时,只需要把这个版本号加一:
redis-cli INCR user:tokenVersion:10001这个操作会让该用户所有旧Token全部失效,而且不需要关心黑名单里存了多少条。我的建议是:单Token失效用黑名单,全量下线用版本号,两者配合才完整。
4. 实现过程中的关键细节与性能优化
4.1 Redis Key的TTL怎么算
黑名单条目理论上只需要存活到Token原本的过期时间。因为Token一旦过了exp,jwt.verify就会拒绝,黑名单存不存都无所谓。所以TTL应该这样算:
TTL = decoded.exp - 当前时间戳这里有两个容易踩的小坑。
第一,decoded.exp是秒,Date.now()是毫秒,换算错TTL就废了。第二,如果TTL太小,比如只剩1秒,可以不用写Redis,直接让Token自然过期就行。如果你给黑名单Key设置了比Token剩余时间更长的TTL,比如固定24小时,那黑名单会积累一堆“已经没用了”的数据,时间久了Redis内存白白浪费。
Key的命名我建议统一加前缀,比如jwt:blacklist:{jti}。这样以后排查问题的时候,用redis-cli keys jwt:blacklist:*就能一把梭查出来,也方便统计黑名单总量。
4.2 多实例部署下需要注意什么
黑名单方案里最怕的不是实现复杂,而是用了“本地状态”。我见过一个项目,登录模块部署了两台机器,退出登录时直接把jti写进某个实例自己的HashMap,结果用户被另一台机器接上请求,照样通过校验。
只要上了负载均衡,黑名单必须放在共享存储里。生产环境Redis一定不要用单机裸奔,至少要主从加哨兵,或者直接上集群。Redis角色切换时,如果主节点数据没同步到从节点,旧Token可能会在极短时间窗口内“复活”。对大多数后台业务来说可以接受,但对风控要求高的系统,可以考虑把黑名单落盘到数据库,或者给Redis开AOF持久化。
还有一点,如果你的Redis开启了maxmemory-policy allkeys-lru这类淘汰策略,黑名单Key属于“频繁写入、但每个Key只短暂存活”的数据,在内存紧张时可能被提前淘汰。这样相应的旧Token就会重新可用,相当于失效被“取消”了。建议给Redis预留足够内存,并单独关注已淘汰Key的数量。
4.3 黑名单膨胀问题如何解决
很多人一听黑名单,第一反应是“这玩意儿会不会越攒越多”。确实会,但只要处理得当,它不会失控。
由于每个条目都设置了和Token剩余时间一致的TTL,Redis会在Key过期后自动清理。所以黑名单大小理论上被控制在一个“Token有效期周期”内签发且被提前作废的Token数量。比如Token有效期2小时,那黑名单最多存最近2小时拉黑的Token,这就是一个天然的上限。
如果Redis的过期清理不及时,你还可以用redis-cli定期执行SCAN配合TTL做一轮主动清理,但我一般不会主动做,Redis的主动过期机制已经够用了。真正需要注意的反而是“Token有效期太长”。如果你把Token设置成7天,那黑名单就要维护7天内的数据,内存和失效窗口都会变大。这也是为什么现在很多团队会把Access Token缩短到几十分钟,另配RefreshToken续期,黑名单只负责短Token的最后一道防线。
4.4 黑名单和短过期策略怎么配合
实际生产环境里,我不会只用黑名单一个手段,而是会做“组合拳”:
- Access Token保持短时效,一般15分钟到2小时。
- Refresh Token负责续期,有效期可以到7天甚至更长。
- 退出登录时,把当前Access Token拉黑,同时把对应的RefreshToken也标记失效。
- 修改密码时,用版本号让所有Token失效。
这样设计的好处是,即使黑名单因为某种原因丢了,旧Access Token也很快自然过期,攻击者能利用的时间窗口非常短。RefreshToken则需要单独的黑名单或版本号保护,因为它的有效期太长,不能放松。
5. 常见问题与排查实录
5.1 问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 退出后再用旧Token仍然能访问 | 中间件没有查黑名单,或查黑名单的位置在业务之后 | 先看日志确认有没有走到redis.exists那一步 |
| Redis重启后旧Token又生效 | Redis未持久化,黑名单Key丢失 | 开启AOF/RDB,或接受这个短窗口风险 |
| 只有部分机器上退出生效 | 黑名单写进了本地内存,而不是Redis | 改用共享Redis存储 |
| 旧Token已过期但黑名单里还有一堆Key | TTL设置太长,或没设置TTL | 检查Redis Key的TTL是否等于Token剩余时间 |
| 一台设备退出,其他设备也被踢了 | 黑名单Key用了userId,而不是jti/tokenHash | 改成每个Token独立标识 |
| 报错说TTL非法 | 时间单位混用,exp是秒,Date.now()是毫秒 | 统一转成秒再计算 |
| 老Token没有jti字段,无法拉黑 | 签发Token时没设置jwtid | 改用Token的SHA256哈希作为Key |
5.2 我踩过的三个坑
第一个坑是中间件顺序。当时我把黑名单校验写在了业务逻辑之后,等于接口都已经开始处理数据了,才想起来“哦这个Token好像拉黑了”。那次线上反馈“退出了还能下单”,查日志才发现顺序完全反了。黑名单校验必须在jwt.verify之后、业务方法之前,尤其是写操作接口。
第二个坑是幂等处理。退出登录接口如果被重复调用,第一次把jti写进了黑名单,第二次拿着同一个Token再来,jwt.verify还能过,但Redis里已经有这个Key了。这时候再SET一次会把TTL重置,理论上没什么危害,但如果你用的是SETNX或者其他有条件的写入,就要先考虑重复拉黑的情况。我对退出接口的设计是:Token已经无效就正常返回成功,别报错。
第三个坑是本地开发和测试环境时间不一致。有一段时间测试反馈“Token刚发出去立刻失效”,查了一圈发现是测试机没做时间同步,导致exp和服务器当前时间差了好几分钟。后来我在所有环境里统一用服务器时间,并且在jwt.verify之后算TTL,而不是依赖客户端传过来的时间。
5.3 我给的建议
如果你正要上黑名单方案,我建议先把“失效粒度”定义清楚。你需要的是“单个Token失效”,还是“某个用户的所有Token失效”?前者用jti黑名单,后者用版本号。不要混着写,更不要让一个方案同时干两件事。
另外,黑名单只是让JWT从“完全无状态”变成“有状态校验”,它并非银弹。安全级别要求高的系统,内核仍然是短过期时间加版本控制。每次请求多一次Redis查询这件事,别过度优化,先保证正确,再考虑缓存提速。
最后想分享一个小技巧:给所有和登录态相关的Redis Key加上统一前缀,并在日志里打印出关键步骤的Key名。以后出了线上问题,你直接用redis-cli查一下jwt:blacklist:{jti}存在不存在,比对着代码猜半天要快得多。这个习惯帮我省过很多次定位时间。