恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
JWT黑名单机制:如何让已签发的Token立即失效
首页
资讯中心
/
JWT黑名单机制:如何让已签发的Token立即失效
JWT黑名单机制:如何让已签发的Token立即失效
发布时间:2026/10/15 12:16:05
做过后台接口鉴权的同学基本都会撞上同一个尴尬情况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 不用黑名单的话还有哪些替代方案我在设计初期也对比过其他方案不一定每个项目都适合上黑名单先看一下取舍方案核心思路优点局限短过期 RefreshTokenAccess Token只有15分钟过期后用RefreshToken续期有效缩短“旧Token可用窗口”15分钟窗口内仍无法立即失效改回服务端Session会话状态保存在服务端踢人、退出、禁用都容易又回到了服务端存储失去JWT无状态优势白名单只保存当前有效的Token校验时查是否存在逻辑直接天然能做踢人每个有效请求都依赖存储像Session但更分散黑名单保存已失效Token的标识校验时查是否存在只关心“作废的”不增加正常Token的存储负担多一次存储查询需要维护过期清理黑名单比较适合“大多数Token都正常使用、只有少数需要提前作废”的业务。如果系统里几乎每个Token都需要频繁失效那不如直接上白名单。这一点想清楚后面代码写起来才不会拧巴。2. 黑名单方案的整体设计与存储选型2.1 黑名单的核心思路黑名单的逻辑可以浓缩成三步签发Token时给每个Token分配一个唯一ID一般叫jtiJWT ID。需要提前失效时把这个Token的jti写进黑名单存储并保留到该Token原本的过期时间。每次请求进来在JWT验签之后、业务逻辑之前查一下这个jti是否在黑名单里。如果在直接返回401。这个方案之所以好用是因为它不和具体用户绑定而是和“某一个Token”绑定。同一个用户登录了三个设备拿到了三个不同的jti只让其中一个失效另外两个完全不受影响。还需要注意黑名单里存的是“Token标识”不是“用户ID”。如果只存用户ID那用户在一台设备退出其他所有设备都会被误伤。我的经验是但凡有人跟你说“用Redis存一个setkey是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时把用户的当前版本号写进Payloadfunction 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一旦过了expjwt.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剩余时间一致的TTLRedis会在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已过期但黑名单里还有一堆KeyTTL设置太长或没设置TTL检查Redis Key的TTL是否等于Token剩余时间一台设备退出其他设备也被踢了黑名单Key用了userId而不是jti/tokenHash改成每个Token独立标识报错说TTL非法时间单位混用exp是秒Date.now()是毫秒统一转成秒再计算老Token没有jti字段无法拉黑签发Token时没设置jwtid改用Token的SHA256哈希作为Key5.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}存在不存在比对着代码猜半天要快得多。这个习惯帮我省过很多次定位时间。