跳到正文

JWT 认证流程:访问令牌、刷新令牌与吊销

端到端讲解 JWT 认证:签发和校验访问令牌、轮换刷新令牌、在浏览器中存放令牌,以及提前吊销。

· 创始人兼开发者

发布于

一张有三条生命线的时序图:浏览器应用、授权服务器和订单 API。登录返回访问令牌和刷新令牌。应用携带 Bearer 令牌调用 API。一个 opt 片段展示过期情况:401、刷新请求、新的令牌对、重试。

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 或网关

逐步拆解流程

  1. 登录。 用户在授权服务器上完成认证,通常使用带 PKCE 的 OAuth 授权码流程。
  2. 令牌响应。 授权服务器返回一个短期有效的访问令牌(一个 JWT,比如有效期 15 分钟)和一个长期有效的刷新令牌。
  3. 调用 API。 应用在每个请求中发送 Authorization: Bearer <access token>。
  4. 本地校验。 API 用授权服务器的公钥验证签名,这个公钥是它从 JWKS 端点获取一次后缓存下来的,然后再检查各项声明。每个请求都无需调用授权服务器。
  5. 过期。 15 分钟后,API 返回 401 Unauthorized。
  6. 刷新。 应用把刷新令牌发送到授权服务器的令牌端点,获得新的访问令牌;如果启用了轮换,还会获得新的刷新令牌。
  7. 重试。 应用用新的访问令牌重新发送失败的请求。

第 3 步和第 4 步正是团队选择 JWT 的原因:成千上万的 API 请求,却不需要共享的会话存储。第 5 步和第 6 步则是代价,大部分安全工作都集中在这里。

校验令牌:检查清单

每个接受 JWT 的 API 都应按以下顺序执行同样的检查:

  1. 只允许预期的算法。 永远不要轻信 alg 头部。RFC 8725 描述了两种经典攻击:把 alg 设为 none;以及把 RS256 令牌改写成 HS256,让天真的库把公钥当作 HMAC 密钥来验证它。
  2. 从缓存的 JWKS 中按 kid 选择密钥,遇到未知的 kid 时重新获取密钥集,但在短时间内最多只获取一次。
  3. 验证签名。
  4. 检查 iss 和 aud。 为其他 API 签发的令牌必须在这里失败。
  5. 检查 exp 和 nbf,并留出较小的时钟容差,例如 60 秒。
  6. 检查令牌类型。 RFC 9068 规定的访问令牌带有 typ: at+jwt,可以防止 ID 令牌被当作访问令牌重放。
  7. 然后再做授权:范围(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 流程图

一张有三条生命线的时序图:浏览器应用、授权服务器和订单 API。登录返回访问令牌和刷新令牌。应用携带 Bearer 令牌调用 API。一个 opt 片段展示过期情况:401、刷新请求、新的令牌对、重试。

5 个组件 · 12 个连接

在 Archboard 中打开
图表内容
  • 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
浏览器应用登录后,用短期有效的 JWT 访问令牌调用 API,访问令牌过期时再使用会轮换的刷新令牌。API 用从授权服务器 JWKS 端点获取的密钥在本地校验令牌。

这张图的读法如下:

  • 三条生命线。 左边是浏览器应用,中间是授权服务器,右边是订单 API。时间自上而下流动。
  • 登录和令牌响应位于顶部:应用获得一个访问令牌和一个刷新令牌。
  • 正常路径:发往 API 的 Bearer 请求及其响应。API 用缓存的 JWKS 密钥校验令牌,从不调用授权服务器。
  • opt 片段涵盖过期情况:401、刷新、旧刷新令牌失效后的新令牌对,以及重试的请求。
  • Markdown 块展示了解码后的令牌和各项校验检查。

画出评审者能核对的认证流程图

  • 在每个箭头上标明每个令牌,包括类型和有效期,例如“访问令牌(JWT,15 分钟)”。对哪个令牌流向哪里含糊其辞,会掩盖真正的缺陷。
  • 标出校验发生的位置。 在负责验证签名的生命线上加一条便签,并说明其密钥从哪里来。
  • 画出失败路径。 过期、刷新令牌重用和登出都应放进片段里;大多数事故都从这里开始。
  • 不要把机密画进图里。 使用占位值;图被分享的范围往往超出你的预期。

时序图示例详细介绍了生命线和片段,零信任架构图则展示了令牌检查在更大的访问模型中所处的位置。

在 Archboard 中构建

  1. 使用 Google 或 GitHub 登录,并新建一张图。
  2. 添加三个时序节点包中的 生命线 节点:Browser app、Auth server 和 Orders API。
  3. 自上而下把消息画成生命线之间的连线:登录、令牌响应、Bearer 请求及其响应。为每条消息标注它携带的令牌。
  4. 在过期路径外围添加一个时序节点包中的 组合片段,运算符设为 opt:401、刷新请求、新的令牌对、重试。
  5. 添加一个基础节点包中的 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 用公钥校验,而私钥只由授权服务器持有;只要服务不止一个,这就是更安全的默认选择。

免费开始 — 1 个席位,无需信用卡。

相关文章

← 返回博客

一分钟打开画布

使用 Google 或 GitHub 登录。单席位免费,团队加入后再升级。