Skip to content
This page has been auto-translated and may contain errors.View in English

JSON Web Tokens

不透明令牌需要查表:字符串本身没有任何含义,所以服务器必须去问自己的存储,这串字符到底属于谁。

去掉查表这一步,就得有别的东西来顶替它。JSON Web Token(简称 JWT)的做法,就是把答案直接放进令牌本身。

三段式,用点分隔的一整串字符

JWT 看起来像一堆乱码,但其实结构非常严格。它由三部分组成,用点号连接:

text
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjQ4MjEsInJvbGUiOiJ0ZWFjaGVyIn0.4pcPyMD09olPSyXnrXCjTwXyr4BsezdI1AVTmud2fU4
└────────── header ──────────┘ └────────── payload ─────────┘ └────────── signature ──────────┘
  • Header(头部)。 说明用的是哪种算法签名,以及标明这是一个 JWT。
  • Payload(负载)。 身份相关的信息,称为claims(声明):用户是谁、拥有什么角色、令牌何时过期、由谁签发。
  • Signature(签名)。 一种密码学印章,证明内容没有被篡改过。

服务器生成 JWT 的方式是:拿到 header 和 payload,与只有自己知道的密钥结合,算出签名。然后把这三部分统一编码、拼接在一起。

任何人都能读懂一个 JWT。但只有掌握密钥的人,才能做出服务器会认可的那种。

正因如此,它不用查表也能被信任——这一句话就是它的核心思路。

Juno三段式,用点分隔的一整串字符 拿信封上的火漆印章来打比方很贴切。任何人都能看到这个印章,但只有拿着印章模具的人才能做出一模一样的。

也就是说,印章并不会隐藏信里的内容。它证明的是谁封的信,以及信自封好之后没人拆开过。

Juno三段式,用点分隔的一整串字符 把一个真实的令牌粘贴到解码工具里,你马上就能看到明文 JSON 格式的 header 和 payload,完全不需要密钥。拿你正在做的项目里的一个令牌试一次;这样下一节内容才会真正落地。

真正抵抗你的是签名部分。它只会告诉你是或否,而且前提是你手里有密钥。

Juno三段式,用点分隔的一整串字符 header 是攻击者可见、也可以由攻击者自行提供的,这正是经典 JWT 伪造手法的根源。把 alg 设为 none,去掉签名,任何"解码"而非"验证"令牌的服务器,都会照单全收你随意编造的 payload。

这种攻击已经在 jwt.decode 中间件上被真实演示过,效果就是:你声称自己是谁,就能拿到谁的合法会话。

正确做法是在 jwt.verify 里明确锁定你要接受的内容:显式指定算法,同时锁定 issueraudience。永远不要让令牌自己告诉你该怎么验证它。

顺带记住一个单位问题:clockTolerance 的单位是秒,不是毫秒。

整个流程,以及变化在哪

  1. 用户用用户名和密码登录。
  2. 服务器验证这些信息。
  3. 验证通过后,服务器创建一个 JWT 并签名。
  4. 令牌发给客户端,由客户端保存。
  5. 之后的每次请求都通过 Authorization 请求头携带它,和不透明令牌的用法一模一样。
  6. 服务器验证签名并读取其中的 claims。不需要查表,也不需要存储。

第六步是与不透明持有者令牌唯一不同的地方,而这一点彻底改变了整个模型:

不透明持有者令牌JWT
内容什么都没有,只是一个随机字符串编码后的 claims:用户 id、角色、过期时间
识别用户的方式查表验证签名,读取 claims
服务器存储一张小的令牌-用户对照表
身份信息存放位置服务器上令牌内部

不透明令牌只是指向保存在服务器上的身份信息。JWT 则是直接携带身份信息。

Juno整个流程,以及变化在哪 只有一步发生了变化,但正是这一步决定了其余一切。

查表意味着服务器每次都能拿到最新的答案。而读取令牌,得到的只是令牌创建那一刻成立的答案。

Juno整个流程,以及变化在哪 不需要存储意味着不用维护共享的会话存储,不用做跨实例协调,水平扩容也变得毫不费力。这是实实在在的运维优势,也是 JWT 在微服务架构中常被选用的原因。

代价会在你需要立刻终止某个会话的那一天出现。没有任何记录可删,所以令牌会一直有效直到过期。要提前为这一天做打算,而不是等出了事才想起来。

Juno整个流程,以及变化在哪 角色信息保存在令牌里,实际用起来就是个隐患。把一个管理员降级,他手上现有的令牌在过期之前依然写着 admin,你的权限变更会有一段延迟,长短取决于你设定的令牌有效期。

有两种解决办法,代价各不相同。一种是让令牌的生命周期非常短(几分钟级别),并频繁刷新,但这又在刷新时重新引入了查表;另一种是维护令牌 id 的允许列表或拒绝列表,这也等于把你去掉的那次查表又找了回来。

说句实话,这两种做法本质上都是无状态模型在花力气把一部分"状态"买回来。这仍是个不错的设计,只是并不像宣传的那样是"零成本"的收益。

四种出问题的方式

JWT 本质上仍是一种持有者令牌,所以所有不透明令牌的陷阱都同样适用:偷到它,你就是那个用户。而它"自包含"这个特性,又额外带来了四个陷阱。

出了什么问题为什么重要解决办法
1以为它是加密的它只是编码,不是加密。任何拿到它的人都能读出 payload 内容。对应 STRIDE 中的信息泄露,OWASP 中的敏感数据暴露payload 里不要放任何你不愿意写在明信片上的内容
2密钥太弱或泄露猜出或偷到密钥,攻击者就能签发自己的令牌,包括一个自称是 admin 的令牌。对应权限提升使用足够长的随机密钥,从环境变量加载,绝不提交到代码库
3payload 塞得太满令牌大意味着每个请求的请求头都大:请求变慢、代理不堪重负、日志臃肿。而且里面塞的一切,一旦令牌泄露也会跟着泄露只放服务器每次请求都需要的内容
4没有设置过期时间JWT 是没法撤销的,没有过期时间就意味着它永远有效。对应身份验证机制失效一定要设置过期时间,而且要短

陷阱 2 是那种能把小失误变成彻底沦陷的问题。一个泄露的会话 id 只牵连一个账号;而一个泄露的签名密钥,牵连的是所有账号,包括还没创建的账号。

Juno四种出问题的方式 第一个陷阱几乎人人都会中招,因为 JWT 看上去确实像一堆乱码。

但它并不是被打乱了。它只是用一种不方便肉眼阅读的"字母表"写成的,任何解码工具都能立刻把它还原成明文。

Juno四种出问题的方式 密钥应该放在环境变量里,绝不能进代码库,而"以后再轮换"这种想法需要一个真正的计划,因为轮换密钥会让所有正在使用的令牌瞬间全部失效。

在轮换期间同时支持两个有效密钥,才是让这件事平稳过渡的关键:用新旧两个密钥都做验证,签名只用新密钥,等旧的令牌都过期了再彻底停用旧密钥。

Juno四种出问题的方式 算法的选择决定了谁有能力签发令牌。HS256 是对称算法,签名和验证用的是同一个密钥,所以任何能验证令牌的服务,也同样能自己造一个。RS256 是非对称算法:只有一个服务持有私钥、负责签名,其他所有服务都只用公钥来验证。

如果只是单一应用,用 HS256 完全没问题。但在多个服务之间,对称签名会在不知不觉中让每一个"验证方"同时具备了"伪造方"的能力,这通常不是任何人本意想要的结果。

另外值得知道的是,JWT 不一定非得是访问令牌。它本质上是一种容器格式,把它用作重置密码的链接或邮箱验证令牌,都是常见且合理的做法。

每一种用途都应该带上一个用途声明(purpose claim),这样为某个用途签发的令牌,就不能被拿去冒充另一个用途使用。

动手试一试

一次无状态安全审计,四个场景。

js
// 1. 令牌查找表中的一条记录
const tokenTable = [
  { token: 'a91f...', userId: 4821, expires: '3000-01-01T00:00:00Z' },
]

// 2. 每次请求都会调用
function authenticate(token) {
  const record = tokenTable.find((r) => r.token === token)
  return record ?? null
}

// 3. 放进某个 JWT 的 payload
{ id: 4821, name: '思远', admin: false, streetAddress: '朝阳门内大街14号',
  phone: '+86 138 0013 8000', tabSwitches: 14, windowResizes: 3 }

// 4. 签发 JWT 时使用的服务器配置
const config = { secret: 'secret123', algorithm: 'HS256' }
对照一下你的答案
#陷阱解决办法
1令牌有效期过长。要到公元 3000 年才过期,几乎可以肯定是笔误,而且这意味着它会作为一个有效凭证持续存在整整一千年把有效期设成 30 分钟
2无法撤销。这段代码只是找到记录就直接返回,从来没检查过 expires,所以过期的令牌照样能通过验证expires 和当前时间比较,过期了就返回 null,顺便把这一行数据删掉
3payload 塞得太满,造成信息泄露。街道地址和电话号码放在了任何持有者都能读到的地方,旁边还塞了些服务器根本用不上的统计数据只留 idnameadmin,别的都不要
4密钥太弱,而且没设过期时间。secret123 很容易被猜到,还被硬编码在代码里,也就是说它已经进了代码库从环境变量里取一个足够长的随机密钥,再加上过期时间的声明

场景 2 是最难被发现的一个。这个函数看上去没问题,它确实能找到令牌,对不存在的令牌也确实会返回 null。但存了过期时间却从来不检查,效果跟根本没设过期时间没有任何区别。

接下来往哪走

两种无状态模型,一个是用查表换来了可撤销性,另一个是用可撤销性换来了扩展性。但不管哪一种,你的应用依然要自己负责密码、重置流程,以及证明"某人到底是谁"的整套机制。

OAuth 与委托身份要问的是:这份责任,你到底想不想自己扛。