原文:https://dev.to/lovestaco/sessions-vs-jwts-you-are-choosing-how-often-you-pay-for-state-196m(作者 @lovestaco)
你写过的每一个应用,都要在每一个请求上回答同一个问题。
这是谁?他有权做这件事吗?
流行的答案有两种。
一种是 Session:由服务器记住你是谁。另一种是 JWT:服务器递给你一张签过名的字条,然后立刻忘掉你的存在。
互联网的主流论调基本已经定了:JWT 是现代方案,Session 是你爷爷在 PHP 时代用的老古董。
这个认知框架是错的,而且它会把人引进一个非常具体的坑。我想带你把这个坑完整地走一遍。
我们从流程讲起,因为差异全藏在细节里。
Session:衣帽寄存模型
你登录,服务器校验密码。校验通过后,它在某个地方写入一行记录。
这行记录保存着你的用户 id、过期时间,可能还有你的角色。它放在 Redis 里、Postgres 里,或者——如果你胆子够大——放在内存里。
然后服务器发回给你一个 cookie,里面只有一样东西:一个随机 id。
就这么简单。这个 cookie 并不是你的身份,它只是一张寄存凭条。

看这张图的下半部分,那才是关键所在。
登录之后的每一个请求,服务器都会拿着你的 session id 去存储层问一句:“这又是谁?”
你的身份信息从来不在 cookie 里。
它每次都是现场取出来的、全新的。
这带来一个被人们严重低估的后果:服务器可以立刻改变对你的认定。
删掉那行记录,这个 cookie 发来的下一个请求就成了陌生人。封禁用户、强制登出、吊销一个已泄露的 session——所有这些操作都只是一条 DELETE。
JWT:签名字条模型
同样的登录,同样的密码校验。
但这一次服务器不写记录,而是构造一个小的 JSON 对象,给它签名,然后把整个东西交到你手上。

这个 token 由三部分组成,用点号连接:header、payload、signature(头部、载荷、签名)。
想看正式规范的话,定义在 RFC 7519 里。
关于这个 payload,有一点最重要,也是我在生产代码里反复见到人们搞错的地方:
# 取出任意 JWT 的中间段,然后直接……读它
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq
{
"sub": "user_8823",
"email": "maneshwar@example.com",
"role": "admin",
"exp": 1735689600
}
不需要密钥,不需要密码,只需要 base64。
JWT 是签名的,不是加密的。 任何拿到 token 的人都能读出里面的每一条 claim,jwt.io 在浏览器里就能帮你做到。
签名并不隐藏内容。
它只证明这些内容在服务器签名之后没有被改动过。
所以,凡是你不愿意印在明信片上的东西,都不要放进 JWT 的 payload。
不放密钥,不放你不想让用户看到的内部标志位,更不放 "isTrialAbuser": true。
真正的区别:真相存在哪里
先把缩写放一边。
用 Session 时,真相放在你的服务器上,客户端持有的是指向它的指针。
用 JWT 时,真相揣在客户端的口袋里,你的服务器手里只有一套核验笔迹的办法。
其余所有差异都从这一句话推导出来。

多加一台服务器,差别立刻显现。
Session 要求每台服务器都能访问同一个存储,这意味着每个请求都要多一次网络跳转,而且你的架构里又多了一个绝对不能宕机的组件。
JWT 不需要共享任何东西。
每台服务器都有密钥,每台服务器都在本地完成校验,再加第四台服务器根本不算个事。
这一点确实很棒,也是 JWT 席卷微服务的原因。
但请看两栏的最底部——那才是你付账的地方。
没人会放上幻灯片的那部分
决定整件事的问题,不是“哪个扩展性更好”。
而是:从你决定让某人登出,到他真正被登出之间,发生了什么?

对会话(session)来说,这个间隔只有一次请求。你删掉那一行记录,下一个请求就失败,完事。
对普通 JWT 来说,这个间隔就是剩余有效期还剩多久。
你可以把这个用户从数据库里删掉、禁用他的账号、吊销他的 API 密钥、放火烧了整栋楼。
令牌照样有效。
每个看到它的服务器都会兴高采烈地验证签名、发现它合法、然后照常处理请求。
这不是 bug。
这就是设计本身。无状态意味着没有服务器会向任何一方核实,而“这个用户现在被封禁了”是一条必须存放在某处的信息。
📌 Bugs Bunny No meme: the stateless auth layer flatly refusing to revoke a token(图,点击查看)
于是我们发明了 refresh token,然后一件有趣的事发生了
标准解法人尽皆知。
把 access token 做成短效的,15 分钟左右,再配一个长效的 refresh token。
access token 过期时,客户端悄悄用 refresh token 换一个新的。
用户毫无感知。
被盗令牌的暴露窗口从几天缩短到几分钟。
这套方案确实有效,你也应该这么用。但请盯着这个 refresh 端点多看一会儿:
app.post("/auth/refresh", async (req, res) => {
const { refreshToken } = req.body;
// 就是它
const stored = await redis.get(`refresh:${refreshToken}`);
if (!stored) return res.sendStatus(401); // 已被吊销,或从未存在过
const { userId } = JSON.parse(stored);
const user = await db.users.findById(userId);
if (user.disabled) return res.sendStatus(401); // 上次刷新之后才被封禁
return res.json({ accessToken: signAccessToken(user) });
});
数一数这里面做了几件事。
一次存储查询。一次吊销检查。一次数据库访问,看看这个用户是否还有资格进来。
这就是会话。你写出来的就是一个会话。
refresh token 是一个指向服务端状态的不透明 id,你随时可以删掉它——而这恰好就是那个我们号称已经抛弃的东西的准确定义。
区别只在于:你现在每 15 分钟检查一次状态,而不是每个请求都检查。
而这才是真正的答案。 你不是在有状态和无状态之间做选择。
你是在选择愿意隔多久为状态付一次费,以及能容忍中间错多久。
会话每个请求都付费,但从不犯错。普通 JWT 从不付费,但可能错上好几个小时。
refresh token 偶尔付费,大约会错 15 分钟。
该选哪种签名算法——这其实是个信任问题
照本宣科的版本是:“HMAC 是对称的,RSA 和 ECDSA 是非对称的。”没错,但这话把重点埋掉了。
真正的问题是:有多少个服务能签发令牌?

用 HMAC 时,验证令牌的密钥和签发令牌的密钥是同一把。
所以拿到这把密钥的每一个服务,都能为任意用户、任意角色伪造令牌,而其他所有服务都会把它当真货收下。
在单个单体应用内部,没问题。一旦跨团队,或任何接近第三方的地方,仅仅为了让某人能验证一个签名就散发这么大的信任,代价太重了。
用 RSA 或 ECDSA 时,认证服务持有私钥,其他所有人都拿公钥。
他们可以整天验证,却造不出一个令牌。公钥泄露不损失任何东西,因为它本来就是公开的。
顺带说一个值得了解的坑。令牌自己的 header 里声明了该用哪种算法,而历史上的那些库就直接信了。
攻击者把 alg 设成 none,或者把 RS256 的配置偷换成 HS256,让公钥被当作 HMAC 密钥来用。
Auth0 专门写过这一批经典漏洞的复盘。
现代的库已经做了防御,但你还是应该自己固定算法:jwt.verify(token, key, { algorithms: ["RS256"] })。永远别让令牌自己选。
Token 存在哪里,决定了它会被怎么偷走
这部分内容经常被人跳过,而大多数真实发生的安全事件恰恰出在这里。
localStorage 用起来方便,页面上的任何 JavaScript 都能读到它。
这意味着只要有一个有问题的 npm 依赖、或者一个 XSS 漏洞,你的 token 就没了。浏览器没有任何机制能阻止这种事。
HttpOnly cookie 则完全无法被 JavaScript 读取,这一整类窃取手段直接被消灭。
代价是浏览器会自动附带 cookie,而这正是 CSRF 攻击所利用的特性,所以你需要设置 SameSite=Lax 或 Strict,并在会改变状态的请求上附加 token。
注意刚刚发生了什么。
如果你把 JWT 放进 HttpOnly cookie,再去服务端核对一张吊销列表,你等于绕了一圈回到了 session——只是多出了几个步骤,cookie 还更大。
这不是反对 JWT 的论据,而是在提醒你先想清楚:自己真正想要的是哪种性质。
OWASP 的会话管理速查表值得你在这里花二十分钟读一读。
那到底该用哪个?
从约束条件出发,而不是从缩写出发。
flowchart TD
A[Picking auth] --> B{Need instant revocation?}
B -->|Yes| S[Sessions]
B -->|No| C{Already run Redis or a shared DB?}
C -->|Yes| S
C -->|No| D{Many services must verify?}
D -->|No| S
D -->|Yes| E{Trust every service?}
E -->|Yes| H[JWT + HMAC]
E -->|No| R[JWT + RSA]
简短版结论:
- 在做只有一个后端的普通 Web 应用? 用 session。它更简单、能即时吊销,而且你的框架本身就自带这套东西。你基本不可能大到连 Redis 都撑不住。
- 在处理钱、健康数据,或任何要求“现在注销”必须是字面意义上的现在的场景? 用 session,或者带吊销列表的 JWT——那不过是戴了顶帽子的 session。
- 很多服务或第三方需要验证身份,又不必调用你的认证服务? 用 JWT。这正是它的用武之地,也是货真价实的超能力。
- 选了 JWT? 短有效期的 access token、可吊销的 refresh token、非对称密钥、锁死算法、HttpOnly cookie。Sven Slootweg 的《Stop using JWT for sessions》是对 JWT 炒作的一剂清醒剂,即便你不同意其中的观点,也值得一读。
我一再看到的模式是:团队因为 JWT 听起来是更“可扩展”的选择而选了它,然后陆陆续续外挂上吊销列表、refresh token 存储和黑名单,最后相当于把 session 拙劣地重造了一遍。
如果你要的是 session 的性质,就用 session;要的是 token 的性质,就用 token。
只是别选了 token,再花六个月把 session 的性质一点点补回去。
原文:https://dev.to/lovestaco/sessions-vs-jwts-you-are-choosing-how-often-you-pay-for-state-196m(作者 @lovestaco)



