写这篇的主要原因是记录一下jwt无状态特点下的主动撤销和登出的解决方案,因为之前负责做过类似的用户相关的系统,对于这一块的方案和实现比较粗糙的,登录成功后,计算完整 JWT 字符串的 Hash,将tokenHash + userId存入Redis,每次鉴权时计算请求 Token 的 Hash,去存储中比对是否存在,这套方案虽然实现了登出撤销,但把所有有效 Token 全部落地存储,JWT 只剩下签名验签的能力,几乎退化成传统 Session 模式,丢失了 JWT 无状态的设计初衷。
最近特意看了下这一块一些开源框架的实现思路,顺便记录梳理一下各类可行方案、优缺点以及如何落地。
web系统多半是前后端分离的架构,而JWT是目前Web 应用最流行的身份认证方案。对于JWT它最大的优势就是无状态。登录成功后,认证服务颁发一个Token给到客户端,客户端业务操作请求带上 Token,认证服务只需要验签、验过期时间,验证通过就可以确认身份了。

有做过开发经验的小伙伴,对于上面描述的这部分理解起来应该没啥难度,服务器只做2件事
++1.验证账密颁发一定期限的Token++
++2.校验Token对不对,过没过期++
标准流程下服务端是完全没有存会话的,但这套机制放到真实业务中,就会碰到一些实实在在的问题,「小王上午 9 点登录系统,拿到一个有效期 8 小时Token」例如:
10 点他主动退出登录了,但是旧 Token 还没过期。
他在操作时管理员需要把他踢下线。
9点30他在另外一个地方登了系统,我需要过期掉9点这次登录的Token。
如果使用标准流程下实现,哪怕主动退出,强制踢出,或者顶掉了登录,我们再次拿着旧的Token去请求接口,是依然能通过鉴权的,从真实业务和系统安全上来说,我都主动退出了,拿去请求接口怎么还可以,服务端不是应该拒绝的吗?
如果使用纯 JWT是拒绝不了的。只要 签名 /iss/aud 正确、没有过期,JwtBearer 就会认为它有效。就算浏览器客户端把 Token 从本地删掉,那也只表示这个浏览器不再使用它,不代表别人复制走的 Token 也失效。其实基于上面这3点真实业务需求,本质上就是要实现,无论何种方式的注销登录,我们应该要使打开大门的钥匙(token)也要失效
那么接下来就聊一下如何在实际业务中使用 JWT + 可撤销会话 来实现解决这个问题,先说如何做:
给 JWT 加
session_id,然后在每个请求查询服务端会话状态,可以实现主动下线和会话撤销,但是这样搞了就不是严格意义上的无状态认证了,而是在 JWT 验签后额外引入了一次状态校验。
上面说的这句话很重要。不要既想要立刻踢人,又要服务端完全无状态,它们两不可能同时成立。
1.JWT 验证的是什么
我们通常理解的就是一个登录凭证,但更准确的理解它证明的是:
-
token是哪个服务颁发 -
token有没有过期 -
token里面的数据有没有被篡改
因为这三点都依赖于 JWT 的第三段签名。它用服务端的密钥对前两段内容做了加密签名,验证时只要签名校验通过,就能确认签发方、有效和数据完整性。但是它不包含:
-
这次登录是否仍被允许。
-
用户是否刚被禁用。
-
这台设备是否已被踢下线。
2.常见方案梳理
那么我们怎么解决上面这个问题呢,知道了问题所在,那解决这个问题就好办了,是token本身的问题我们就从token入手,通常有如下方案网上一搜,都差不多介绍,但是没有绝对的好坏,选用合适的就行
| 方案 | Token 中放什么 | 服务端保存什么 | 更适合什么场景 |
|---|---|---|---|
jti 黑名单 |
每个 Token 一个 jti |
已作废 Token 的 jti,直到 Token 过期 |
主动退出登录 |
| Token Version / Security Stamp | 用户级版本号 | 用户当前版本号 | 改密码、禁用用户、全端下线 |
session_id 白名单 |
一次登录一个会话 ID | 活跃会话记录 | 在线设备列表、踢指定设备、单设备登录 |
1. jti 黑名单
签发 Token 时加入随机 jti,注销登录时把 jti 放进 Redis,过期时间和 Token 对齐,每个请求验签后再查它是否在黑名单。优点就是它只记录已经作废的少数 Token。但如果需要展示在线设备、踢某一台设备、黑名单很快会变得绕。因为它只记录了谁被干掉了,不知道哪些还活着。

1.登录成功后,生成一个全局唯一的 jti,写入 JWT 的 Payload 中,然后把 Token 返回给客户端。这样每张 Token 都有一个唯一的标识号了。
2.每次请求解析出 jti,查 Redis 黑名单,如果存在就拒绝
3.当需要撤销某个Token ,例如用户主动登出从 Token 中解析出将 jti 写入 Redis,TTL 设为 Token 剩余有效期。
4.写入黑名单的时候设置了 TTL,所以 Token 本身的有效期到期后,Redis 会自动删除对应的黑名单条目。黑名单不会一直增长,也不需要手动维护。
2.Token Version
在用户表里面存一个 token_version 或 security_stamp。生成的Token 里面也带一份,每次请求比较用户表和token中的版本号或guid随机的tag,不一样就拒绝,比较适合改密码后让所有旧登录失效,账号禁用后全部下线。不过它是用户维度的,一般做不到精确踢掉某台设备而不影响其他设备。

1.用户表中新增字段 token_version,默认值为 1。字段记录当前用户 Token 的版本号,每次需要使旧 Token 失效时,版本号 +1。
2.用户登录成功后,从数据库查询用户当前的版本,然后写入 JWT 的 Payload 中,然后签发 Token 返回给客户端。这样每张 Token 都记住了发token时的版本号。
3.客户端携带 Token 请求受保护资源时,服务端先验签、校验过期时间,通过后从 Token 中解析出 token_version,再去数据库查询该用户当前的 token_version。两者一致则放行,不一致则返回 401,要求客户端重新登录。
4.当使某个用户的所有旧 Token 失效时,如修改密码、退出所有设备,只需将用户表的版本号加 1。由于旧 Token 中记录的版本号已经落后,下次请求时会比对失败,所有旧 Token 立即失效。
3. session_id 白名单
session_id 白名单每次登录生成一个唯一的 session_id,写入 JWT 的同时在数据库里面存储,验证时检查该 session_id 是否存在于数据库白名单中,不存在则拒绝。与黑名单记录已失效的不同,白名单只认登记过的,所以对支持会话管理和设备控制更方便直接。

- 生成唯一
session_id,写入 JWT,同时在数据库插入一条会话记录。 - 每次请求解析出
session_id,查数据库是否存在且未过期,存在放行然后续期,不存在就拒绝。 - 删除指定
session_id的会话记录, Token 立即就失效了,其他设备不受影响。
JWT验证只依赖签名密钥和 Token 本身,任意的服务实例都能实现支持独立完成校验。加入会话白名单以后,请求会变成:
- JWT 验签。
- 查询 session_id 是否有效。
- 接受或拒绝本次身份。
4.方案对比
把前面三个放一起对着看看,定位就清晰了
-
jti 黑名单:适合只做登出或单个 Token 撤销的场景,成本比较低,但看不到全局在线设备,也无法定点踢用户。
-
Token Version:动静比较大用户改密码、封号一键生效,简单粗暴,但粒度太粗,做不到设备级隔离。
-
session_id 白名单:能查在线列表、可以精确踢设备,代价是每次请求都要查存储,会破坏了JWT无状态的特点。
实际选型时,主要看需要管到什么程度,如果只是让用户自己登出、改密码后失效,黑名单或版本号都够用,但如果管理员要看到谁在登录、从哪登录、并能单独踢掉某个设备,只能选择白名单了。
到这里你可能会问,那有没有其他方法呢?有的!!我第一次知道这个方法,是在极客时间上听一个老师讲的,瞬间豁然开朗,因为颁发和校验token时都是使用秘钥来校验的,换密钥能让所有已签发的 Token 瞬间全部失效,但它属于终极大招,太猛了不分敌我,全部干掉的,实际中应该没人这么做吧,除非密钥泄露,一些重大安全事件、需要全站强制登出才会这么干。
3.落地白名单
方案聊完了,下面开始实现,Demo中用个内存字典来作为持久化角色的容器,生产换成 Redis 或数据库就行。先把这块存储写出来,三个很简单的方法 Save 写入、IsValid 查询、Revoke 删除。
cs
public class UserSession
{
public required string SessionId { get; init; }
public required string Username { get; init; }
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
public DateTimeOffset LastAccessed { get; set; } = DateTimeOffset.UtcNow;
}
public class UserSessionStore
{
private readonly ConcurrentDictionary<string, UserSession> _sessions = new();
public void Save(string sessionId, string username) =>
_sessions[sessionId] = new UserSession { SessionId = sessionId, Username = username };
public bool IsValid(string? sessionId)
{
if (string.IsNullOrEmpty(sessionId) || !_sessions.TryGetValue(sessionId, out var session))
return false;
session.LastAccessed = DateTimeOffset.UtcNow; // 续期:记下最后活动时间
return true;
}
public void Revoke(string sessionId) => _sessions.TryRemove(sessionId, out _);
public int RevokeAll(string username)
{
var keys = _sessions.Where(x => x.Value.Username == username).Select(x => x.Key).ToList();
foreach (var key in keys)
_sessions.TryRemove(key, out _);
return keys.Count;
}
}
1. 签发

登录成功后要生成 session_id 然后写入白名单,再把同一份 claim 写进 JWT
cs
[AllowAnonymous]
[HttpPost("login")]
public IActionResult Login([FromBody] LoginRequest request)
{
if (request.Username != "admin" || request.Password != "123456")
return Unauthorized();
var sessionId = Guid.NewGuid().ToString("N");
_sessions.Save(sessionId, request.Username);
var claims = new[]
{
new Claim(ClaimTypes.Name, request.Username),
new Claim("session_id", sessionId)
};
var (token, expiresUtc) = _tokenService.GenerateToken(claims);
return Ok(new { token, sessionId, expiresUtc });
}
2.请求校验

每个已认证请求走两步。Jwt验签这一步是无状态的,再查白名单有状态。会话失效时不要改 JWT,只需要把 HttpContext.User 清空就好了,后面的Authorize 会 401。我们选择定义一个中间件 SessionValidationMiddleware
cs
public class SessionValidationMiddleware
{
private readonly RequestDelegate _next;
public SessionValidationMiddleware(RequestDelegate next) => _next = next;
public async Task InvokeAsync(HttpContext context, UserSessionStore sessions)
{
if (context.User.Identity?.IsAuthenticated == true)
{
var sessionId = context.User.FindFirst("session_id")?.Value;
if (sessionId is not null && !sessions.IsValid(sessionId))
{
// 验签已过,但这次登录已被撤销
context.User = new ClaimsPrincipal(new ClaimsIdentity());
}
}
await _next(context);
}
}
但是注入的位置要放在认证和鉴权的中间, 先UseAuthentication() → 会话中间件 SessionValidationMiddleware → UseAuthorization()。
cs
app.UseAuthentication();
app.UseMiddleware<SessionValidationMiddleware>();
app.UseAuthorization();
3.会话撤销

撤销不会改已经发出的 Token,但是客户端仍可拿旧 JWT 来访问接口,由于无状态只要没过期验签会过,但是走到IsValid 失败后变成 401
cs
[Authorize]
[HttpPost("logout")]
public IActionResult Logout()
{
var sessionId = User.FindFirst("session_id")?.Value;
if (string.IsNullOrEmpty(sessionId))
return BadRequest();
_sessions.Revoke(sessionId); // 主动退出:只废当前这把钥匙
return Ok();
}
[HttpPost("sessions/{sessionId}/revoke")]
public IActionResult RevokeSession(string sessionId)
{
_sessions.Revoke(sessionId); // 踢指定设备
return Ok();
}
[HttpPost("users/{username}/revoke-all")]
public IActionResult RevokeAll(string username)
{
_sessions.RevokeAll(username); // 全端下线
return Ok();
}
所以按照这几步下来,开始那三个问题就能被解决了,主动退出调 logout,管理员踢人调 revoke,换地方登录把旧 session 干掉。旧 Token 就算存在也没用,下次请求 IsValid 过不去,Authorize 直接就 401了。