不透明持有者令牌
一张门禁卡能让你进入办公楼。卡上没有名字,没有照片,里面也没有任何可读的信息。你刷一下卡,读卡器对照名单核实,门就开了,或者不开。
不透明持有者令牌就是这张卡。一串随机、无法猜测的字符串,里面什么信息都没有。
无状态不代表服务器什么都不记得
这个词很容易让人误解,所以有必要先把它说清楚。
无状态意味着服务器不保存会话:没有一个对象在多次请求之间追踪某个用户。它依然有数据库,依然有用户数据,依然存储各种东西。它不再保存的,是"这个人现在处于登录状态"这条记录。
于是每次请求都必须自带服务器判断"这是谁"所需要的全部信息。
服务器不再记住你;客户端每次都要重新证明自己。
这个名字里的两个词各有含义。不透明(Opaque)指的是服务器无法从令牌里读出任何信息,因为里面本来就没有内容——它不是经过编码或有结构的数据,只是一串随机字符串。持有者(Bearer)指的是谁拿着它,谁就能用它。
它唯一不再保留的,是一张写着"你现在处于登录状态"的便条。而令牌,正是用来替代这张便条的东西。
整个流程
一共六步,形状你会觉得眼熟:
- 用户提交用户名和密码。
- 服务器进行校验。
- 校验成功后,服务器生成一个随机令牌。
- 服务器存储一条简单的"令牌对应用户"映射,然后把令牌发给客户端。
- 客户端保存这个令牌,并在之后每次请求的
Authorization请求头中带上它。 - 服务器查找这个令牌,找到对应的用户,请求便通过了身份验证。
GET /api/orders
Authorization: Bearer 7f3a9c1e5b28d4a06e91f7c3和会话与 cookie相比,差异主要体现在三个地方:
| 有状态会话 | 不透明持有者令牌 | |
|---|---|---|
| 服务器存储的 | 完整的会话对象 | 一行"令牌对应用户"的记录 |
| 客户端存储的 | 一个保存会话 id 的 cookie | 令牌本身 |
| 传递方式 | 由浏览器自动携带 | 由你在代码中显式添加 |
| 适用场景 | 传统 Web 应用 | API 和移动应用 |
第三行的差异比看起来重要得多。cookie 是不请自来、自动跟随请求的;持有者令牌则是由你自己的客户端代码在每次请求中主动附加的。
区别在于服务器保留了多少信息,以及由谁来发送。骨架相同,重量分布不同。
五种出问题的方式
这套模型本身几乎没什么风险,风险都出在令牌怎么存、能用多久、以及查找表维护得好不好这些环节。
| 出了什么问题 | 为什么要紧 | 怎么解决 | |
|---|---|---|---|
| 1 | 存储不安全 | 存在 localStorage、普通变量或未加保护的 cookie 里的令牌,页面上任何脚本都能读到。只要出现一个 XSS 漏洞,攻击者就和真实用户没有区别了 | 存到更难被访问到的地方,并减少它被暴露的频率 |
| 2 | 令牌有效期太长 | 一个能用好几周的令牌,意味着一旦泄露,攻击者也能用好几周 | 设置较短的过期时间 |
| 3 | 没有吊销机制 | 令牌不会自己过期,登出后也照样能用。没人去检查的过期时间只是摆设 | 每次请求都检查是否过期,登出时删除令牌 |
| 4 | 查找表膨胀 | 每次登录都会写入一行,记录不断堆积,查找越来越慢。攻击者只要不断攻击登录接口就能人为制造这种情况,这属于拒绝服务攻击 | 用定时任务清理过期令牌 |
| 5 | 查找表被篡改 | 这张映射表就是唯一的真相来源。通过 SQL 注入或泄露的凭据获得写入权限,就能把一个令牌重新指向另一个用户 | 像保护应用本身一样,严格保护数据库和管理工具 |
第五种问题特别值得多想一想。令牌本身没有任何变化,变的是它代表的含义。这是一种完全不需要碰凭据本身,就能实现的权限提升。
令牌没毛病,问题都出在它周围的这些环节上。
动手试试
一个 API 是这样签发令牌的:
const token = Math.random().toString(36).slice(2)
tokens[token] = { userId: 4821 }
res.json({ token })又是这样校验令牌的:
const token = req.headers.authorization?.replace('Bearer ', '')
const entry = tokens[token]
if (!entry) return res.status(401).json({ error: 'Unauthorized' })
req.userId = entry.userId找出其中四个问题。
对照一下你的答案
1. Math.random 不是密码学安全的随机数生成方式。 它的输出可以根据之前的值预测出来,所以攻击者只要收集到几个令牌,就能推算出其他令牌。修复方法是用 crypto.randomBytes(32).toString('hex'),单单这一处问题就足以让整套方案被人伪造。
2. 完全没有过期机制。 没有存储令牌的签发时间,进来的请求也不会检查这一点,所以每个签发出去的令牌都永久有效。应该存储过期时间,并在每次请求时检查。
3. 令牌以明文形式存储。 tokens[token] 保存的是原始字符串,所以任何能读到这张表的人,都能使用表里的每一个令牌。应该存储哈希值,查找时对收到的值哈希后再比对。
4. tokens 是一个普通对象,所以查找存在漏洞。 tokens['toString'] 会返回 Object.prototype.toString,这是一个真值(truthy),所以只要请求携带字面量令牌 toString,就能顺利绕过 if (!entry) 这个检查。
这样一来 entry.userId 就会是 undefined。这最终会导致程序崩溃,还是会让请求以用户 undefined 的身份通过验证,取决于后面的代码怎么写。应该使用 Map、Object.create(null) 或 Object.hasOwn()。
第四个问题是那种表面看起来没什么毛病的坑。前三个是需要记住的注意事项,而第四个是 JavaScript 对象本身的特性,已经在真实系统里造成过问题。
接下来会走向哪里
这张查找表让这套模型得以运转,也正是它的局限所在。它给了你吊销令牌的能力,但也意味着每次请求都要访问共享存储——而这恰恰是无状态身份认证本来想要避免的事情。
JSON Web Token 把这个思路推到了极致:彻底去掉查找表,把身份信息直接放进令牌本身。

