Session vs JWT:为什么我们最终放弃了无状态 Token?
Session vs JWT:为什么我们最终放弃了无状态 Token?
最近在做一个企业级管理系统的技术选型时,团队围绕认证方案吵了起来。有人说"JWT 是标配,无状态多香",有人说"Session 更可控,撤销方便"。最后我们选了 Session,理由可能和你想的不一样。
先聊聊背景
我们做的是一套 B 端管理系统,用户主要是企业员工,通过浏览器访问。系统有几个特点:
- 安全性要求高:用户数据敏感,密码重置后必须立即踢掉所有设备
- 多端登录:用户可能同时在电脑、平板、手机上登录
- 网关统一鉴权:所有请求经过网关,网关解析身份后转发给下游服务
JWT 和 Session 都能满足基本需求,但在具体场景下,差异就出来了。
JWT 和 Session 的本质区别
在深入对比之前,先简单回顾一下两者的核心机制。
JWT(JSON Web Token) 是无状态的。用户登录后,服务端生成一个 Token,里面包含了用户 ID、过期时间等信息,用密钥签名后返回给客户端。客户端每次请求带上这个 Token,服务端验签即可,不需要存储任何会话信息。
// JWT 的结构:Header.Payload.Signature
// eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjEyMzQ1fQ.signature
// 服务端只需要验签,不需要查数据库或 Redis
Session 是有状态的。用户登录后,服务端生成一个 Session ID,将会话信息存储在服务端(通常是 Redis),Session ID 通过 Cookie 返回给客户端。每次请求时,服务端根据 Session ID 去存储中查找用户信息。
// Session 的流程:客户端带 Cookie → 服务端用 Session ID 查 Redis
// Redis 中存储了完整的会话信息
一句话总结:JWT 把状态放在客户端,Session 把状态放在服务端。
第一个分歧点:撤销
我们遇到的第一个问题是密码重置。
产品需求很明确:用户修改密码或管理员重置密码后,所有已登录设备必须立即失效,强制重新登录。这个需求很合理,想象一下,员工离职前改了密码,如果旧会话还有效,那安全隐患就大了。
用 Session 实现很简单:
// 密码重置时,直接删除该用户的所有会话
public void resetPassword(String userId, String newPassword) {
// 更新密码
userMapper.updatePassword(userId, bcryptEncode(newPassword));
// 删除该用户的所有 Session,所有设备立即失效
redisTemplate.delete("auth:user_sessions_by_user:" + userId);
}
用 JWT 就麻烦了。JWT 本身是无状态的,服务端签发后就管不了了。要实现撤销,通常有两种方案:
方案一:维护黑名单
每次密码重置时,把该用户的所有 Token 加入黑名单。每次请求时,除了验签,还要查黑名单。
// 密码重置时,把用户 ID 加入黑名单
public void resetPassword(String userId, String newPassword) {
userMapper.updatePassword(userId, bcryptEncode(newPassword));
// 记录撤销时间点
redisTemplate.opsForValue().set(
"auth:token:blacklist:" + userId,
String.valueOf(System.currentTimeMillis()),
7, TimeUnit.DAYS // 保留 7 天,超过 Token 自然过期
);
}
// 每次请求时,检查 Token 是否在黑名单中
// 这样 JWT 的"无状态"优势就没了,每次都要查 Redis
方案二:短有效期 + Refresh Token
把 Access Token 的有效期设得很短(比如 5 分钟),过期后用 Refresh Token 换新的。密码重置时,撤销 Refresh Token 即可。
但这种方案也有问题:在 Access Token 有效期内,还是无法撤销。而且 Refresh Token 的管理本身就和 Session 类似了。
结论:如果需要即时撤销能力,JWT 的无状态优势反而成了劣势。
第二个分歧点:多设备管理
我们的用户经常在多个设备上登录。产品希望用户能看到"当前登录设备列表",还能单独踢掉某个设备。
这个需求用 Session + Redis 实现非常自然:
// Redis 中用 Hash 存储用户的多个会话
// Key: auth:user_sessions_by_user:{userId}
// Field: sessionKey(每个设备一个)
// Value: 会话信息(登录时间、设备类型、IP 等)
public void login(String userId, String deviceType, String ip) {
// 生成 Session Key
String sessionKey = generateSessionKey();
// 存储会话信息
SessionInfo info = new SessionInfo(deviceType, ip, System.currentTimeMillis());
redisTemplate.opsForHash().put(
"auth:user_sessions_by_user:" + userId,
sessionKey,
objectMapper.writeValueAsString(info)
);
// 设置 Cookie
response.addCookie(createSessionCookie(userId, sessionKey));
}
// 获取用户的登录设备列表
public List<SessionInfo> getActiveSessions(String userId) {
Map<Object, Object> sessions = redisTemplate.opsForHash()
.entries("auth:user_sessions_by_user:" + userId);
return sessions.values().stream()
.map(this::parseSessionInfo)
.toList();
}
// 踢掉指定设备
public void kickDevice(String userId, String sessionKey) {
redisTemplate.opsForHash().remove(
"auth:user_sessions_by_user:" + userId,
sessionKey
);
}
用 JWT 实现这个需求就很别扭。每个设备一个 Token,要列出所有设备,就需要存储所有 Token;要踢掉某个设备,就需要维护一个 Token 的撤销列表。最终你会发现,你把 JWT 当 Session 用了,但又没有 Session 的简洁性。
第三个分歧点:安全性
前端存储 Token 的方式一直是个争议点。
localStorage 存 JWT:最常见的做法,但存在 XSS 风险。如果页面有 XSS 漏洞,攻击者可以通过 localStorage.getItem('token') 直接拿到 Token。
Cookie 存 Session ID:可以设置 HttpOnly,JavaScript 无法读取,天然防 XSS。配合 Secure 和 SameSite 属性,安全性更高。
// HttpOnly Cookie:JavaScript 无法读取
Cookie cookie = new Cookie("SESSION", userId + ":" + sessionKey);
cookie.setHttpOnly(true); // 禁止 JS 访问
cookie.setSecure(true); // 只在 HTTPS 下传输
cookie.setSameSite("Lax"); // 防 CSRF
cookie.setPath("/");
cookie.setMaxAge(7 * 24 * 60 * 60); // 7 天
response.addCookie(cookie);
当然,Cookie 有 CSRF 风险,但现代浏览器的 SameSite 属性已经大大缓解了这个问题。而且 CSRF 攻击只能"借用"身份,不能"窃取"身份,危害比 XSS 小得多。
我们的选择:安全性要求高的 B 端系统,HttpOnly Cookie + Session 是更稳妥的方案。
第四个分歧点:网关层实现
我们的架构是微服务,所有请求经过网关。网关需要解析用户身份,然后通过 Header 传递给下游服务。
用 Session 的方案:
// 网关层:解析 Cookie → 查 Redis → 注入 Header
@Component
public class SessionAuthGatewayFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 1. 从 Cookie 中提取 Session 信息
String sessionCookie = extractSessionCookie(exchange);
if (sessionCookie == null) {
return unauthorized(exchange);
}
// 2. 解析 userId 和 sessionKey
String[] parts = sessionCookie.split(":");
String userId = parts[0];
String sessionKey = parts[1];
// 3. 查 Redis 验证会话是否有效
return reactiveRedisTemplate.opsForHash()
.get("auth:user_sessions_by_user:" + userId, sessionKey)
.flatMap(sessionValue -> {
if (sessionValue == null) {
return unauthorized(exchange);
}
// 4. 会话有效,注入 Header 传递给下游
ServerHttpRequest mutatedRequest = exchange.getRequest().mutate()
.header("X-User-Id", userId)
.header("X-User-Name", getUserName(sessionValue))
.header("X-Trace-Id", generateTraceId())
.build();
return chain.filter(exchange.mutate().request(mutatedRequest).build());
});
}
@Override
public int getOrder() {
return -70; // 在限流之后、路由之前执行
}
}
下游服务通过拦截器读取 Header,写入 ThreadLocal:
// 下游服务:读取 Header,写入 ThreadLocal
@Component
public class UserContextInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
String userId = request.getHeader("X-User-Id");
String userName = request.getHeader("X-User-Name");
String traceId = request.getHeader("X-Trace-Id");
if (userId != null) {
// 写入 ThreadLocal,供业务代码使用
UserContext.setCurrentUser(new UserInfo(userId, userName));
TraceContext.setTraceId(traceId);
MDC.put("traceId", traceId); // 日志中也会带上
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler, Exception ex) {
// 必须清理!线程池复用时会泄漏
UserContext.clear();
TraceContext.clear();
MDC.clear();
}
}
用 JWT 的方案类似,只是网关从 Authorization Header 读取 Token,验签后注入同样的 Header。下游服务的拦截器代码完全一样。
所以这一块两者差异不大,主要是网关层解析的逻辑不同。
第五个分歧点:续期策略
用户在使用过程中,如果会话快过期了,应该自动续期,而不是突然被踢下线。
Session 的续期很自然:
// 每次请求时,滑动续期
// Redis 的 TTL 自动重置
public void touchSession(String userId, String sessionKey) {
String key = "auth:user_sessions_by_user:" + userId;
// 设置新的过期时间,相当于续期
redisTemplate.expire(key, 7, TimeUnit.DAYS);
}
JWT 的续期需要 Refresh Token 机制:
// Access Token 过期后,用 Refresh Token 换新的
// 这本身就是一个"有状态"的机制
public TokenPair refresh(String refreshToken) {
// 验证 Refresh Token 是否有效(需要查数据库或 Redis)
if (!isValidRefreshToken(refreshToken)) {
throw new UnauthorizedException();
}
// 生成新的 Access Token
String newAccessToken = generateAccessToken(getUserId(refreshToken));
// 可选:旋转 Refresh Token(每次使用后换新的,防重放)
String newRefreshToken = rotateRefreshToken(refreshToken);
return new TokenPair(newAccessToken, newRefreshToken);
}
你会发现,Refresh Token 的管理逻辑和 Session 非常相似——都需要服务端存储,都需要查库验证。既然如此,为什么不直接用 Session 呢?
那 JWT 什么时候更好?
说了这么多 Session 的优势,并不是说 JWT 一无是处。JWT 有自己的适用场景:
1. 无服务器架构(Serverless)
如果服务端不维护状态,JWT 天然适合。比如静态网站 + API Gateway + Lambda 的架构。
2. 跨域 / 跨平台
Cookie 有域的限制,JWT 可以通过 Authorization Header 传递,跨域更方便。移动端 App 通常也更倾向于用 JWT。
3. 微服务间的信任传递
服务 A 调用服务 B,可以用 JWT 传递用户身份,服务 B 不需要再查存储。
4. 低延迟要求
JWT 不需要查 Redis,理论上更快。但在我们的场景中,Redis 查询的延迟通常在 1ms 以内,可以忽略。
我们的最终方案
综合考虑后,我们选择了 Session + Redis + HttpOnly Cookie 的方案:
// 完整的登录流程
public String login(String username, String password, String deviceType, String ip) {
// 1. 验证用户名密码
User user = userMapper.selectByUsername(username);
if (user == null || !bcryptMatches(password, user.getPasswordHash())) {
throw new BusinessException("用户名或密码错误");
}
// 2. 生成 Session Key(密码学安全随机数)
String sessionKey = generateSessionKey();
// 3. 构建会话信息
SessionInfo sessionInfo = new SessionInfo();
sessionInfo.setUserId(user.getId());
sessionInfo.setUserName(user.getName());
sessionInfo.setDeviceType(deviceType);
sessionInfo.setLoginIp(ip);
sessionInfo.setLoginTime(System.currentTimeMillis());
// 4. 存入 Redis Hash
String redisKey = "auth:user_sessions_by_user:" + user.getId();
redisTemplate.opsForHash().put(
redisKey,
sessionKey,
objectMapper.writeValueAsString(sessionInfo)
);
redisTemplate.expire(redisKey, 7, TimeUnit.DAYS);
// 5. 设置 HttpOnly Cookie
Cookie cookie = new Cookie("SESSION", user.getId() + ":" + sessionKey);
cookie.setHttpOnly(true);
cookie.setSecure(true);
cookie.setSameSite("Lax");
cookie.setPath("/");
cookie.setMaxAge(7 * 24 * 60 * 60);
response.addCookie(cookie);
return "登录成功";
}
这套方案满足了我们的所有需求:
- ✅ 密码重置后即时撤销所有设备
- ✅ 支持多设备管理
- ✅ HttpOnly Cookie 防 XSS
- ✅ 网关统一鉴权
- ✅ 滑动续期
总结
| 维度 | JWT | Session + Redis |
|---|---|---|
| 撤销 | 需要额外机制(黑名单) | 直接删 Key |
| 多设备 | 每个设备一个 Token,管理复杂 | Redis Hash 天然支持 |
| 安全性 | localStorage 有 XSS 风险 | HttpOnly Cookie 防 XSS |
| 续期 | 需要 Refresh Token 机制 | 滑动 TTL |
| 性能 | 不查存储(但黑名单方案会查) | 查 Redis(1ms 以内) |
| 适用场景 | Serverless、跨域、移动端 | B 端系统、安全要求高 |
技术选型没有银弹,关键是要理解每种方案的 trade-off。JWT 的"无状态"是优势也是劣势,Session 的"有状态"是劣势也是优势。选哪个,取决于你的具体场景。
在我们的场景下,Session 赢了。不是因为 JWT 不好,而是因为我们的需求恰好站在 Session 的优势区间里。
更多推荐



所有评论(0)