Session vs JWT:为什么我们最终放弃了无状态 Token?

最近在做一个企业级管理系统的技术选型时,团队围绕认证方案吵了起来。有人说"JWT 是标配,无状态多香",有人说"Session 更可控,撤销方便"。最后我们选了 Session,理由可能和你想的不一样。


先聊聊背景

我们做的是一套 B 端管理系统,用户主要是企业员工,通过浏览器访问。系统有几个特点:

  1. 安全性要求高:用户数据敏感,密码重置后必须立即踢掉所有设备
  2. 多端登录:用户可能同时在电脑、平板、手机上登录
  3. 网关统一鉴权:所有请求经过网关,网关解析身份后转发给下游服务

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。配合 SecureSameSite 属性,安全性更高。

// 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 的优势区间里。


Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐