「译介」别再用 JWT 做会话了

2026-10-06, 星期二, 21:04

译介Cyber SecurityDev

原文 Stop using JWT for sessions 和 Stop using JWT for sessions, part 2: Why your solution doesn’t work,作者 Sven Slootweg(joepie91)

网络安全方面的攻防正在变成 token 消耗的军备竞赛(类似的观点可看 Drew Breunig 的 Cybersecurity Looks Like Proof of Work Now,也许会抽空翻译一下),而个人 / 单独几家公司的能力是有限的,因此软件的构建应该优先选择成熟的,久经考验的方案,而不是自己造轮子。从现在的软件开发情况看,这两篇写于 2016 年的文章仍然是有意义的。

近来我看到越来越多的人建议用 JWT(JSON Web Token)管理 Web 应用里的用户会话。这是个很糟的主意。

先把几个词定下来,免得后面说拧了:

  • 无状态 JWT:会话数据直接编进令牌。
  • 有状态 JWT:令牌里只有会话的引用(或 ID),数据放在服务端。
  • 会话令牌 / cookie:Web 框架用了很多年的那种会话 ID,可以签名,也可以不签。数据放在服务端。

首先澄清一点,这篇文章不是说不应该使用 JWT,只是 JWT 不适合当会话机制。只需换个场景,它依然有正当用途,文末会简单讲讲那些用途。

事先声明

很多人拿「cookie vs. JWT」来比较。这就好比是拿苹果和橘子来分高下。cookie 是一种存储机制,而 JWT 是带密码学签名的令牌。

二者不是对立的,可以一起用,也可以分开用。该比的是「会话 vs. JWT」,以及「cookie vs. Local Storage」。

本文的主要内容是「会话 vs. JWT」,当然偶尔也会涉及「cookie vs. Local Storage」。

JWT 声称的好处

别人推荐 JWT 时,通常会举出下面一条或几条:

  • 更容易横向扩展
  • 更易于使用
  • 更灵活
  • 更安全
  • 自带过期
  • 不用向用户征求 cookie 同意
  • 防 CSRF
  • 在移动端更好用
  • 对禁用 cookie 的用户也有效

我一条一条说它们为什么是错的,或者为什么在误导人。有的解释会含糊一点,主要是因为这些说法本身就含糊。

更容易横向扩展

这条是清单里唯一在技术上有那么一点道理的,而且只在你用无状态 JWT 的时候成立。实际情况是,几乎没有人需要这种扩展能力。更容易的扩展办法多得是。除非你的体量像 Reddit 那么大,否则用不上「无状态会话」。

有状态会话可以这样扩展:

  1. 一台机器上跑多个后端进程:在这台机器上放一个 Redis,专门存会话。
  2. 服务跑到多台机器上:单独一台机器跑 Redis,只存会话。
  3. 多台机器,还分成多个集群:用 sticky session。

这些情况都有现成的软件。你的应用多半连第二步都不需要。

也许你在想,该为应用做点「面向未来」的准备,万一规模真的超过这一档。以后再换会话机制,其实相当简单,代价只是切换时把所有用户登出一次。不值得为了这个事先就上 JWT,尤其是后面要说到的那些缺点。

更易于使用

不对。客户端和服务端的会话管理都得自己做,而标准的会话 cookie 拿来就能用。

更灵活

我还没见过有人讲清楚,JWT 到底灵活在哪儿。主流的会话实现都能往会话里塞任意数据,这和 JWT 没什么区别。就我所见,这只是个时髦词。

更安全

很多人觉得 JWT 更安全,因为用了密码学。签名 cookie 确实比不签名的安全,但这绝不是 JWT 独有的。像样的会话实现也会给 cookie 签名。

「用了密码学」也不会神奇地让一件东西变安全。它得有个具体目的,而且对这个目的真的有效。

「更安全」还有一种我常听到的解释:「它们不是以 cookie 发出去的。」这完全说不通。cookie 不过是一个 HTTP 头,用 cookie 没有任何不安全的地方。事实上,面对恶意的客户端代码,cookie 反而有专门的保护。这点后面会讲。

如果你担心有人截走会话 cookie,该用的是 TLS。不用 TLS,什么样的会话都能被截走,JWT 也一样。

自带过期

这是一句废话,也不是什么有用的功能。过期在服务端照样能做,而且很多实现确实已经这么做了。服务端过期其实更好:应用可以清掉不再需要的会话数据。你要是用有状态 JWT,又只靠令牌自己的过期时间,这件事就做不到。

不用征求 cookie 同意

完全不对。并不存在一种叫「cookie 法」的东西。和 cookie 有关的那些法律,管的是任何一种持久标识,只要它不是服务运转所严格必需的。你能想到的会话机制,都在这个范围里。

简单说:

  • 会话或令牌如果只干功能上的事,比如让用户保持登录,那就不用征求同意。存到哪里,都一样。
  • 如果还拿它做别的,比如统计或跟踪,那就必须征求同意。同样跟你怎么存无关。

防 CSRF

并没有。存 JWT 大致有两种办法:

  • 放在 cookie 里:你照样会受到 CSRF 攻击,照样得防。
  • 放在别处,比如 Local Storage:你不再怕 CSRF 了,可网站现在离了 JavaScript 就不能用,而且你刚把自己暴露给另一类漏洞,这类漏洞可能更糟。下面会讲。

防 CSRF 的正道是 CSRF 令牌。会话用的是什么,在这里无关。

在移动端更好用

没这回事。还在用的移动浏览器都支持 cookie,因此也支持会话。主流的移动开发框架,以及任何正经的 HTTP 库,也都支持。这根本不是个问题。

对禁用 cookie 的用户也有效

不太可能。用户很少只拦 cookie。他们通常会把持久化手段一并拦掉。Local Storage 肯定算在内,其他任何能把会话存下来的地方也算,用不用 JWT 都一样。这是另一回事。想在没有 cookie 的情况下把认证做通,基本是白费劲。

再说,把 cookie 全拦掉的用户,通常明白认证会因此坏掉。他们会给在乎的网站单独加白名单。作为 Web 开发者,更好的办法是告诉用户,为什么这个网站需要 cookie 才能工作。

缺点

上面说了常见的推销观点以及它们为什么站不住脚的理由,你也许会想:「行,那也没什么大不了。就算 JWT 帮不上我,用着用着也没关系。」你想错了。把 JWT 当会话用,缺点不少,其中几条是正经的安全问题。

更占地方

JWT 一点也不小。尤其是无状态 JWT,数据全编在令牌里,很快就会撑破 cookie 或 URL 的长度限制。你也许会改存到 Local Storage。然而……

更不安全

JWT 放在 cookie 里,和其他会话标识没有区别。放到别处,你就多了一类攻击面。Does JWT Put Your Web App at Risk? 的“Storing sessions”一节写过这件事:

We pick up where we left off: back at local storage, an awesome HTML5 addition that adds a key/value store to browsers and cookies. So should we store JWTs in local storage? It might make sense given the size that these tokens can reach. Cookies typically top out somewhere around 4k of storage. For a large-sized token, a cookie might be out of the question and local storage would be the obvious solution. However, local storage doesn’t provide any of the same security mechanisms that cookies do.

Local storage, unlike cookies, doesn’t send the contents of your data store with every single request. The only way to retrieve data out of local storage is by using JavaScript, which means any attacker supplied JavaScript that passes the Content Security Policy can access and exfiltrate it. Not only that, but JavaScript also doesn’t care or track whether or not the data is sent over HTTPS. As far as JavaScript is concerned, it’s just data and the browser will operate on it like it would any other data.

After all the trouble those engineers went through to make sure nobody is going to make off with our cookie jar, here we are trying to ignore all the fancy tricks they’ve given us. That seems a little backwards to me.

说白了:存会话就该用 cookie,跟你用不用 JWT 没关系。

无法作废特定 JWT

安全问题还没完。会话可以由服务器随时作废,想作废某个无状态 JWT 却不行。按设计,没到期它们就一直有效,中间出什么事都不管。比如说,发现入侵之后,你作废不了攻击者的会话。用户改了密码,旧会话也作废不了。

你基本上没招。想「杀掉」一个会话,就得另外搭一套复杂的、而且是有状态的设施,专门把这些令牌认出来再拒绝掉。那一开始用无状态 JWT 图的是什么呢。

数据会过时

和上面那条有关,同样可能变成安全问题。无状态令牌里的数据像缓存一样,放久了就会过时,不再是数据库里的最新版本。

令牌里可能只是留着过时信息,比如某人已经在个人资料里改掉的旧网址。更严重的是,某人手里的令牌还写着 admin,而你刚刚把这个角色撤了。令牌又作废不了,你收不回他的管理员权限,除非把整个系统停掉。

实现不够久经考验,或者压根没有

你也许觉得,这些问题都只出在无状态 JWT 上。大体没错。可有状态令牌,功能上就是一枚普通的会话 cookie,只是没有那些久经考验的实现。

现成的会话实现,比如 Express 的 express-session,已经在生产环境里跑了很多很多年,安全性也因此好了不少。拿 JWT 凑合当会话 cookie,你得不到这些。你要么自己写一套,并且多半会在这个过程里引进漏洞;要么用一个没怎么在真实环境里用过的第三方实现。

结论

无状态 JWT 不能作废,也不能更新。存哪儿都有问题:要么是体积,要么是安全。有状态 JWT 干的事和会话 cookie 一样,却没有久经考验、被人反复看过的实现,客户端也没有现成的支持。

除非你在做 Reddit 那个规模的应用,否则没有理由拿 JWT 当会话。用会话就行。

那 JWT 适合干什么

文章开头我说,JWT 有好的用途,只是不适合当会话。这句话还算数。JWT 特别合适的场合,通常是拿它当一次性的授权令牌。

JSON Web Token 规范里是这么写的:

JSON Web Token (JWT) is a compact, URL-safe means of representing claims to be transferred between two parties. […] enabling the claims to be digitally signed or integrity protected with a Message Authentication Code (MAC) and/or encrypted.

在这里,「声明」可以是一条命令、一次一次性授权,或者任何你能说成下面这样的事:

你好,服务器 B。服务器 A 告诉我,我可以 <这里填声明>,这是(密码学)证明。

比如你在做文件托管。用户下载自己的文件之前得先认证,文件本身却由另一台无状态的「下载服务器」来发。这时可以让应用服务器(服务器 A)签发一次性的下载令牌,客户端再用它去下载服务器(服务器 B)取文件。

这么用的时候,有几条对得上:

  • 令牌是短命的。有效几分钟就够,客户端能把下载发出去就行。
  • 一枚令牌预期只用一次。每次下载,应用服务器都签发一枚新的,所以任一枚只用来要一次文件,然后就扔掉。这里完全没有持久状态。
  • 应用服务器仍然用会话。用令牌的是下载服务器,它只授权这一次下载,因为它不需要持久状态。

所以会话和 JWT 一起用,完全说得通。它们各干各的,有时候你两个都要。只是别用 JWT 去放要长期留着的数据。

译者注:从这里开始是 part 2 部分的文章

为什么你的方案行不通

差不多一周前,我写了一篇,说明为什么不该把 JSON Web Token 当作会话机制。

可惜文章长到某个地步,有些人就不会读完了。Reddit 和 Hacker News 上有不少评论翻来覆去地给出同一套上文中已经讨论过并毙掉的「解决方案」。

所以这一次,我要换一张略带讽刺的流程图。

flowchart TD start["我想这样让 JWT 能用来做会话"] start --> key["作废会话时换掉签名密钥"] start --> revoke["维护一份服务器都能访问的吊销列表"] start --> idOnly["令牌里只放标识,数据放服务端"] start --> ls["改放 Local Storage,地方更大"] start --> short["令牌很快过期,泄露了也不要紧"] key --> keySec["安全问题:拿下服务器后,攻击者可以为所欲为"] keySec -->|"那我换掉签名密钥"| keyUse["可用性问题:一人被攻破,全体被登出"] keyUse -->|"按用户派生各自的密钥"| impl["安全问题:实现不够久经考验"] revoke --> down["吊销服务挂了,怎么办?"] down -->|"未知令牌一律有效"| keySec down -->|"未知令牌一律无效"| pointless["白忙活:你把会话又发明了一遍"] idOnly --> pointless pointless --> impl ls --> lsSec["安全问题:页面上任何 JavaScript 都能偷走它"] short --> shortUse["可用性问题:离线几分钟就得重新登录"] shortUse -->|"那我用 refresh token"| back["安全问题:长期令牌吊销不了,回到原点"] back -.->|"回到原点"| start

脚注:微服务架构

还有一个常出现的说法:微服务架构里,用 JWT 做会话仍然没问题。这个也是错的,只是有点复杂,塞不进上面那张图。

如果客户端直接和各个服务说话,服务大致分两类:

  • 有状态服务:自己有会话,或者有持久化,比如聊天服务。
  • 无状态服务:没有会话这回事,只做一件件独立的任务,比如视频转码。

两种情况下,你都不需要把 JWT 当会话。无状态服务根本没有会话。让应用服务器为每一次获授权的操作,签发短命的、一次性的令牌就行。有状态服务则是:每个服务发一枚新的、短命的、一次性的令牌,再到这个服务上,把它换成该服务自己的会话。令牌本身永远不当会话用。

如果客户端只和应用服务器说话,上面这些都无关。服务之间没有「会话」。来来去去,都是同一处调用方发起的、一件件自包含的操作。这种情况下用 JWT 大概也行,哪怕它不是最合适的。你并没有把它们当会话。