JWT 是令牌格式,Session 是会话状态的存放位置,两者不是非此即彼的对立面:你可以把 JWT 放进 cookie,也可以把 session id 放进 Authorization 头。真正需要决定的是:会话状态究竟放在客户端还是服务端,以及你的系统能不能承受「令牌签发出去之后,在过期前收不回来」这件事。判断顺序建议是:先确认是否需要即时吊销令牌、强制下线,再看调用方是否跨端、跨域或经过多个服务,最后才比较性能和存储成本。
如果没有「封号立刻生效、改权限马上生效」的硬性要求,并且调用方包含移动端、第三方或多服务网关,可以先选 JWT,配合短过期时间和 refresh token;如果主要面对登录后台、需要随时踢人、权限热更新,先选服务端 Session,把存储放到 Redis 这类共享介质即可。两边都能跑通后,再用同一个受保护接口压测一次,不要只凭印象选。
JWT和Session:各自站在什么位置
JWT 的定位是自包含令牌:服务端签发后,把身份信息写进 payload,再用密钥签名;之后每个请求只要签名能验过,就能确认身份。它擅长的是跨服务、跨端、无共享存储的调用场景,网关、开放 API、移动端登录态常见这种用法。代价是签发后的令牌在过期前有效,想让它立刻失效,需要额外准备吊销名单、令牌版本号或很短的过期时间。
Session 的定位是服务端保存状态:浏览器只拿到一个 session id,真正的用户信息放在服务端内存、Redis 或数据库里。它擅长的是需要精确控制登录态的场景,比如后台管理系统、需要封号或强制下线的业务。代价是每个请求通常要查一次存储,多实例部署时存储必须共享。
两边各有一个可以核对的判断口径:

- JWT 看 RFC 7519 里定义的
iss、exp、sub、jti这几个 claim,再看你所用库的校验参数,例如 Node 的jsonwebtoken的algorithms、expiresIn,Java 的 jjwt 的parserBuilder()校验链。核对方式很直接:拿一个真实令牌,看它是不是三段以点分隔的字符串,再看 decode 出来的exp是不是秒级时间戳。 - Session 看框架文档里的会话配置项,例如 Express
express-session的name、cookie.maxAge、store;Spring Boot 的server.servlet.session.timeout、spring.session.store-type;PHP 的session.save_handler、session.gc_maxlifetime。核对方式是看下发的 session cookie 有没有HttpOnly、Secure、SameSite,以及store究竟指向内存还是 Redis。
JWT和Session:性能、成本、运维、上手四个口径分开比
性能这一项,可以从两个方向测量。JWT 省掉了每次请求查询会话存储的时间,但多了验签计算:HS256 是 HMAC 运算,开销很小;RS256 是非对称验签,开销明显更大,具体取决于密钥长度和机器。Session 每个请求通常多一次存储读取,同机 Redis 一般是零点几毫秒级别,跨机房就要看网络往返时间。测量方式:ab -n 2000 -c 50 -H "Authorization: Bearer <token>" http://127.0.0.1:8080/api/me,看 Time per request 和 99% 分位;同时用 redis-cli info commandstats | grep cmdstat_get 观察 GET 的调用次数和耗时。实际业务里,这两项通常都不是主要瓶颈,耗时更多花在数据库查询和序列化上,所以要结合自己接口的耗时分布来判断。
成本这一项,主要看两笔:存储和带宽。Session 需要一块可靠的共享存储,按实例规格计费,具体价格要看所选云厂商 Redis 产品定价页的「规格与内存」一栏,不同厂商、不同地域会不一致。JWT 不占服务端会话存储,但每个请求都要带上令牌,header 体积通常比 session cookie 大不少。测量方式:curl -s -D - -o /dev/null http://127.0.0.1:8080/login | grep -i set-cookie | wc -c,对比两种实现下发凭据的字符数,再乘上日常请求量估算流量,多数场景下这点差异可以接受。
运维这一项,Session 的难点在存储可用性:Redis 挂了或者网络抖动,所有登录态会一起失效,所以一般需要主从或集群,还要确认 key 的过期策略和内存淘汰策略不会误删会话。JWT 的难点在密钥:密钥泄露相当于可以伪造任意用户,轮换密钥时要能让新旧密钥短暂共存,否则轮换瞬间会有一批用户被登出;同时要提前想好吊销怎么处理。JWT 还多一个「令牌过期后怎么续」的运维动作,refresh token 的存储和失效逻辑要单独设计。
上手这一项,Session 通常更快:框架中间件几行配置就能跑,登录时写 req.session.user,请求时读出来即可。JWT 要自己写清楚签发、验签、过期处理、刷新和登出五段逻辑,出问题的点更多。如果你的团队规模不大、上线时间紧,先把 Session 跑通往往更稳妥。

JWT和Session:什么情况选哪一边
内部管理后台、单体或少量实例的应用:建议用服务端 Session。前置条件是你已经有一块共享存储(Redis 或数据库会话表),并且能接受每次请求多一次读操作。这个组合的好处是封号、改角色、强制下线都能立刻生效,排查问题时直接看存储里的会话就能对上人。
面向移动端、第三方或经过网关的开放 API:建议用 JWT。前置条件是 access token 的有效期设得比较短,并配套 refresh token 或重新登录流程;如果业务确实需要即时吊销令牌,就再加一份吊销名单(按 jti 记录),并明确名单的存储和清理策略。没有这层兜底,一旦需要封禁某个调用方,就只能等令牌自然过期。
浏览器端加服务间调用混合的架构:可以两边都用。浏览器侧仍用带 HttpOnly 的 session cookie,服务之间的内部调用改用 JWT 传递身份,前置条件是内部服务之间已有可信的密钥分发方式,并且网关会校验并剥离外部传入的同名头。这种拆法能兼顾浏览器安全和跨服务免查存储,但配置项更多,需要有人专门维护两套凭据的过期策略。

JWT和Session:一次能复现的对比做法
下面这组命令在本机十分钟内能跑完,用来对比「验签计算」和「会话存储读取」这两笔开销的量级,以及两种凭据的体积差异。先把两个终端准备好,一个跑 Redis,一个执行命令。
# 1) HS256 验签计算开销:重复 10 万次 HMAC
node -e 'const c=require("crypto");const k="demo-secret";const t=process.hrtime.bigint();for(let i=0;i<100000;i++){c.createHmac("sha256",k).update("{\"sub\":\"1\",\"exp\":9999999999}").digest("base64url")}console.log("hmac x100k ms:",Number(process.hrtime.bigint()-t)/1e6)'
# 2) Redis 单次 GET 的吞吐与延迟(本机)
redis-server --port 6379 &
redis-benchmark -t get -n 100000 -c 50 -q
# 3) 凭据体积:分别看 Set-Cookie 的字符数
curl -s -D - -o /dev/null http://127.0.0.1:8080/login | grep -i set-cookie | wc -c
预期输出的差异在哪:第 1 条输出的是 10 万次 HMAC 的总毫秒数,除以 10 万就是单次验签的粗略量级,属于纯 CPU 开销,与网络无关。第 2 条 redis-benchmark 给出的是本机 GET 的每秒请求数和延迟分布,跨机房时这个数字会被网络往返时间拉长,需要在你真实部署的机器之间重测。第 3 条把 JWT 方案和 Session 方案各跑一次,通常能看出 JWT 的凭据字符数明显更多,这就是每个请求多出来的带宽成本。
一个容易踩的坑:把 JWT 存进 localStorage,再在前端用 JS 拼到请求头里。这种做法一旦页面存在 XSS,令牌会被直接读走,而且服务端很难察觉。如果确实要在浏览器里用 JWT,建议放在 HttpOnly、Secure、SameSite 都设置好的 cookie 里,并配套 CSRF 防护;同样,Session 方案也不要只设置 name 和 maxAge 就上线,cookie 的安全属性缺一项,前面的方案比较就失去意义。