IT加油站

Session 还是 JWT:本质是在选择为状态付费的频率

11浏览 2天前 软件教程 MA123068

原文: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 分钟。

📌 Charlie Day conspiracy board meme: connecting the red string from stateless JWTs back to sessions(图,点击查看)

该选哪种签名算法——这其实是个信任问题

照本宣科的版本是:“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=LaxStrict,并在会改变状态的请求上附加 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)

#JWT #Session #身份认证 #Token #后端开发