JSON Web Token 之所以流行,是因为 API 不用调用任何人就能校验它:验证签名,读取声明,完事。但这一特性也是大多数 JWT 问题的根源。无法查询的令牌,就是无法收回的令牌;团队往往在第一次有笔记本电脑被盗或员工离职时才意识到这一点。
本文借助访问令牌、刷新令牌和一张时序图,完整讲解一个 JWT 认证流程,然后介绍每个 API 都需要的校验检查、令牌在浏览器中该放在哪里、刷新令牌轮换、密钥轮换,以及切实可行的吊销方案。
JWT 是什么,又不是什么
JWT(RFC 7519)是一个紧凑、URL 安全的字符串,由三段以点分隔的 base64url 内容组成:头部、包含声明的载荷和签名。解码后,一个访问令牌长这样:
{ "alg": "RS256", "kid": "2026-10", "typ": "at+jwt" }
{
"iss": "https://auth.example.com",
"sub": "user_8412",
"aud": "orders-api",
"scope": "orders:read orders:write",
"iat": 1793606400,
"exp": 1793607300,
"jti": "a3f1c9e2"
}
大多数误解来自以下三点:
- 签名的 JWT 并没有加密。 任何持有令牌的人都能读取载荷。不要把持有者不该看到的机密或个人数据放进去。
- JWT 是一种格式,而不是协议。 客户端如何获得令牌由别的规范定义,通常是 OAuth 2.0 和 OpenID Connect。OAuth 2.0 流程图介绍了这部分内容。
- 流程中的令牌并不都是 JWT。 访问令牌通常是;刷新令牌则往往是只有授权服务器才能查询的不透明随机字符串。
每个令牌里都会出现的注册声明:
| 声明 | 含义 | 由谁检查 |
|---|---|---|
iss |
签发者,即签发该令牌的授权服务器 | API |
sub |
主体,通常是用户 ID | API,用于授权 |
aud |
受众,即令牌面向的 API | API |
exp、nbf、iat |
过期时间、生效时间和签发时间,以自纪元起的秒数表示 | API |
jti |
唯一令牌 ID,适合用于拒绝列表 | API 或网关 |
逐步拆解流程
- 登录。 用户在授权服务器上完成认证,通常使用带 PKCE 的 OAuth 授权码流程。
- 令牌响应。 授权服务器返回一个短期有效的访问令牌(一个 JWT,比如有效期 15 分钟)和一个长期有效的刷新令牌。
- 调用 API。 应用在每个请求中发送
Authorization: Bearer <access token>。 - 本地校验。 API 用授权服务器的公钥验证签名,这个公钥是它从 JWKS 端点获取一次后缓存下来的,然后再检查各项声明。每个请求都无需调用授权服务器。
- 过期。 15 分钟后,API 返回
401 Unauthorized。 - 刷新。 应用把刷新令牌发送到授权服务器的令牌端点,获得新的访问令牌;如果启用了轮换,还会获得新的刷新令牌。
- 重试。 应用用新的访问令牌重新发送失败的请求。
第 3 步和第 4 步正是团队选择 JWT 的原因:成千上万的 API 请求,却不需要共享的会话存储。第 5 步和第 6 步则是代价,大部分安全工作都集中在这里。
校验令牌:检查清单
每个接受 JWT 的 API 都应按以下顺序执行同样的检查:
- 只允许预期的算法。 永远不要轻信
alg头部。RFC 8725 描述了两种经典攻击:把alg设为none;以及把 RS256 令牌改写成 HS256,让天真的库把公钥当作 HMAC 密钥来验证它。 - 从缓存的 JWKS 中按
kid选择密钥,遇到未知的kid时重新获取密钥集,但在短时间内最多只获取一次。 - 验证签名。
- 检查
iss和aud。 为其他 API 签发的令牌必须在这里失败。 - 检查
exp和nbf,并留出较小的时钟容差,例如 60 秒。 - 检查令牌类型。 RFC 9068 规定的访问令牌带有
typ: at+jwt,可以防止 ID 令牌被当作访问令牌重放。 - 然后再做授权:范围(scope)、角色以及对资源的所有权。
在 Node.js 中使用 jose 库,清单中的大部分检查只需一次调用:
import { createRemoteJWKSet, jwtVerify } from 'jose'
const JWKS = createRemoteJWKSet(new URL('https://auth.example.com/.well-known/jwks.json'))
export async function verifyAccessToken(token: string) {
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'https://auth.example.com',
audience: 'orders-api',
algorithms: ['RS256'],
typ: 'at+jwt',
clockTolerance: 60,
})
return payload // scopes and sub are checked by the caller
}
访问令牌应该有效多久?
有效期就是被盗令牌可用且无法收回的时间窗口。越短越安全,但刷新流量也越多。以一个在八小时工作日内持续活跃的用户为例:
| 访问令牌有效期 | 被盗后的暴露时长 | 每个用户每天的刷新次数 |
|---|---|---|
| 5 分钟 | 5 分钟 | 96 |
| 15 分钟 | 15 分钟 | 32 |
| 1 小时 | 1 小时 | 8 |
| 24 小时 | 24 小时 | 1 |
大多数团队把访问令牌的有效期定在 5 到 15 分钟之间。有效期一整天的访问令牌,就是一个无法吊销的会话 Cookie。
刷新令牌轮换
刷新令牌的有效期长达数天乃至数周,所以泄露一个刷新令牌比泄露访问令牌更糟。OAuth 安全的当前最佳实践 RFC 9700 要求,对于浏览器应用和移动应用这类公共客户端持有的刷新令牌,必须采用以下两种保护之一:
- 发送方约束的刷新令牌,通过双向 TLS 或 DPoP 绑定到客户端持有的密钥。
- 轮换:每次刷新都返回新的刷新令牌并使旧令牌失效,授权服务器则记住整条令牌链。如果攻击者和合法客户端使用同一个令牌,其中一方就会提交已失效的令牌,服务器随即吊销当前有效的刷新令牌,从而终结双方的令牌链。
轮换把一个被盗的刷新令牌从持续数周的问题,变成在合法客户端下次刷新时就结束的问题。代价是:用户需要重新登录;同时你的令牌端点要能处理两个标签页同时刷新的情况,而不误触发重用检测,通常的做法是设置一个很短的宽限期。
令牌在浏览器中放在哪里
| 存储位置 | JavaScript 可读 | 主要风险 | 说明 |
|---|---|---|---|
localStorage |
是 | 任何 XSS 都能窃取令牌 | 刷新页面后仍保留;不要用于刷新令牌 |
| 仅内存 | 是,页面存续期间 | 会话期间的 XSS | 刷新页面后丢失;启动时重新刷新 |
HttpOnly Cookie |
否 | CSRF | 使用 Secure、SameSite,并为修改状态的请求使用 CSRF 令牌 |
| 后端服务前端(BFF) | 否;令牌留在服务器上 | 服务端会话管理 | 浏览器只持有一个会话 Cookie |
IETF 关于浏览器应用使用 OAuth 的草案,把 backend for frontend 模式描述为让令牌完全远离 JavaScript 的方案。如果你把令牌留在浏览器里,就把访问令牌放在内存中,把刷新令牌放在作用域限定为令牌端点的 HttpOnly Cookie 中。
吊销:哪些方法真正有效
JWT 一旦签发就无法撤回签名。你只能让 API 不再接受它,而每种做法都要放弃一部分无状态性:
| 策略 | 工作原理 | 代价 |
|---|---|---|
| 短有效期 | 令牌几分钟后自行失效 | 无;暴露窗口依然存在 |
| 吊销刷新令牌 | 下一次刷新失败,访问在下次过期时终止 | 在本就有状态的令牌端点做一次查询 |
按 jti 设置拒绝列表 |
网关在列表中的 ID 达到 exp 之前一律拒绝 |
一个共享缓存,每个请求都要检查 |
| 按用户设置截止时间 | 拒绝在该用户已存储的时间戳之前签发的令牌 | 每个用户一次查询,可缓存 |
| 令牌内省 | API 就每个令牌询问授权服务器(RFC 7662) | 一次网络调用;只用于高风险端点 |
一个实用的组合是:访问令牌有效期不超过 15 分钟;使用会轮换的刷新令牌,并在登出和修改密码时吊销;再为少见的“立即在所有设备上登出”场景设置按用户的截止时间。
轮换签名密钥
密钥会泄露,算法会老化,所以从第一天起就要规划轮换。在开始用新密钥签名的大约一天前,就把新公钥发布到 JWKS 中,确保每个 API 的缓存里都有它。然后把签名切换到新的 kid。旧密钥要保留在 JWKS 中,直到最后一个用它签名的令牌过期,再将其移除。遇到未知 kid 就重新获取密钥集的 API,可以在不重启的情况下扛过一次紧急轮换。
JWT 流程图
图表内容
- Browser app
- Auth server
- Orders API
- access token expired
- 1. Sign in (OAuth code flow + PKCE)
- 2. Access token (JWT, 15 min) + refresh token
- 3. GET /orders, Authorization: Bearer JWT
- GET /.well-known/jwks.json (once, then cached)
- public keys
- 4. 200 OK
- 5. GET /orders (expired JWT)
- 6. 401 Unauthorized
- 7. POST /token (refresh token)
- 8. New token pair, old refresh token invalidated
- 9. Retry GET /orders with new JWT
- 10. 200 OK
这张图的读法如下:
- 三条生命线。 左边是浏览器应用,中间是授权服务器,右边是订单 API。时间自上而下流动。
- 登录和令牌响应位于顶部:应用获得一个访问令牌和一个刷新令牌。
- 正常路径:发往 API 的 Bearer 请求及其响应。API 用缓存的 JWKS 密钥校验令牌,从不调用授权服务器。
- opt 片段涵盖过期情况:
401、刷新、旧刷新令牌失效后的新令牌对,以及重试的请求。 - Markdown 块展示了解码后的令牌和各项校验检查。
画出评审者能核对的认证流程图
- 在每个箭头上标明每个令牌,包括类型和有效期,例如“访问令牌(JWT,15 分钟)”。对哪个令牌流向哪里含糊其辞,会掩盖真正的缺陷。
- 标出校验发生的位置。 在负责验证签名的生命线上加一条便签,并说明其密钥从哪里来。
- 画出失败路径。 过期、刷新令牌重用和登出都应放进片段里;大多数事故都从这里开始。
- 不要把机密画进图里。 使用占位值;图被分享的范围往往超出你的预期。
时序图示例详细介绍了生命线和片段,零信任架构图则展示了令牌检查在更大的访问模型中所处的位置。
在 Archboard 中构建
- 使用 Google 或 GitHub 登录,并新建一张图。
- 添加三个时序节点包中的 生命线 节点:Browser app、Auth server 和 Orders API。
- 自上而下把消息画成生命线之间的连线:登录、令牌响应、Bearer 请求及其响应。为每条消息标注它携带的令牌。
- 在过期路径外围添加一个时序节点包中的 组合片段,运算符设为
opt:401、刷新请求、新的令牌对、重试。 - 添加一个基础节点包中的 Markdown 块,写上解码后的令牌和校验清单。
这个示例共用 5 个图表节点,在 Free 套餐的限制之内。
常见问题
JWT 是加密的吗? 签名的 JWT 不是。载荷只是经过 base64url 编码,任何持有令牌的人都能读取。加密令牌可以使用 JSON Web Encryption,但大多数访问令牌只做了签名。
应该把 JWT 存在 localStorage 里吗? 任何长期有效的东西都不要这样存。任何一个跨站脚本漏洞都能读取 localStorage。把访问令牌放在内存中、刷新令牌放在 HttpOnly Cookie 中,或者把令牌保存在后端服务前端(BFF)里。
既然 JWT 是无状态的,怎么让用户登出? 吊销刷新令牌,使其无法再换取新的访问令牌,同时保持访问令牌的有效期很短。如果需要立即生效,就增加按用户的截止时间戳或 API 会检查的 jti 拒绝列表。
ID 令牌和访问令牌有什么区别? ID 令牌告诉客户端是谁登录了,是给客户端用的。访问令牌是给 API 用的。API 应当拒绝 ID 令牌;typ 和 aud 检查能明确做到这一点。
应该用 HS256 还是 RS256? HS256 使用一个共享密钥,因此每个能校验令牌的服务也都能签发令牌。RS256 或 ES256 让 API 用公钥校验,而私钥只由授权服务器持有;只要服务不止一个,这就是更安全的默认选择。