KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
03 · 令牌与校验:三种东西,六项检查 — keel 龙骨
02 章末尾换回来三样东西:accesstoken、idtoken、refresh_token。 它们都长得像 JWT、都是三段式、都能被解出 JSON——但它们不是一个东西。 把它们当成同一个东西,会直接产生两类线上问题:拿身份结论去调接口, 或者把一把能调接口的钥匙放进浏览器。
02 章末尾换回来三样东西:
access_token、id_token、refresh_token。
它们都长得像 JWT、都是三段式、都能被解出 JSON——但它们不是一个东西。
把它们当成同一个东西,会直接产生两类线上问题:拿身份结论去调接口,
或者把一把能调接口的钥匙放进浏览器。
一、现场:三个「都是 token」的误用
误用一:拿 ID Token 去调 API。 接口返回 401。ID Token 的使命是告诉应用「这个人是谁」,
不是给人去访问资源的凭据。它常常连 aud 都不指向资源服务器。
误用二:把 access token 存进 localStorage。 任何一段注入到页面里的脚本都能读走它,
然后以这个用户的身份调接口——直到它过期为止。存哪里这件事没有「都能过」的选项,
只有暴露面的取舍。
误用三:把角色塞进令牌,然后指望「撤权立刻生效」。 令牌一旦签发就是自包含的:
里面的角色在过期之前都不会变。撤权要么等它过期,要么另建一套撤销机制。
三个误用都来自同一个根:没分清三种 token 的归属方与寿命。
二、三种 token 各自回答什么
flowchart TD
subgraph IDP["IdP(OP)"]
T1["令牌端点"]
end
subgraph APP["应用(SP / RP,服务端)"]
A1["读 ID Token:认人"]
A2["存 refresh token:续期"]
A3["透传或持有 access token:访问资源"]
end
subgraph RES["资源(后端 API / userinfo)"]
R1["校验 access token"]
end
subgraph UA["浏览器"]
U1["页面上的前端代码"]
end
T1 -->|"id_token<br/>aud=client_id"| A1
T1 -->|"refresh_token<br/>aud=issuer"| A2
T1 -->|"access_token<br/>aud=account"| A3
A3 -->|"Bearer"| R1
A3 -.->|"把 access_token 交给前端:暴露面换一种形式"| U1
U1 -.->|"localStorage 里的 access token 被脚本读走"| R1
R1 -.->|"401 invalid_token(过期 / 用途不对)"| A3
A2 -.->|"invalid_grant(refresh token 过期或已失效)"| A2
A1 -.->|"invalid_id_token(六项校验任一不符)"| A1
同一个 realm 里三个 token 的真实差别:
| ID Token | Access Token | Refresh Token | |
|---|---|---|---|
| 回答什么 | 这个人是谁 | 这个应用能访问什么 | 还能不能再换一次 |
aud |
sp-spa(client_id) |
account |
realm 的 issuer |
typ |
ID |
Bearer |
Refresh |
| 谁读它 | 应用(认证结论) | 资源服务器(授权判断) | 只有 IdP 认它 |
| 典型寿命 | 与 access token 同寿(本课主案例里都是 300 秒) | 300 秒 | 1800 秒 |
| 该不该给浏览器 | 可以(它只是结论) | 要想清楚,见本章第五节 | 不该 |
看出来了吗:光凭「都叫 token」这件事,你无法判断哪一个是身份、哪一个是钥匙。
判断依据是 aud 与 typ。
一个顺带值得注意的事实:本课主案例里 refresh token 也是 JWT
(header 里 alg 是 HS512,typ 是 Refresh)。但规范并不要求它是 JWT——
refresh token 完全可以是一段不透明字符串,只有 IdP 自己能解释它。
不要假设 refresh token 能被解出什么来,那是实现细节。
三、JWT 的三段结构与 kid
三种 token 都长成这样:
base64url(header) . base64url(payload) . base64url(signature)
一个真实的 ID Token 头部:
header = {"alg": "RS256", "typ": "JWT", "kid": "6sKOrtKwkpycDAQbwZzpRKJEE04b5-Zy3wCoCUI0ff4"}
签名段长度 = 342 (base64url 字符数)
两件必须一次说清的事:
- 中间那段不是加密,只是编码。 任何拿到 token 的人都能把它 base64url 解回 JSON。
所以绝不要把秘密放进 claim。安全来自第三段(签名)与后面对各项 claim 的校验,
而不是来自「别人看不出来」。 kid是「用哪把钥匙验」的索引。 它让 IdP 可以同时公布多把密钥,
于是换密钥时旧 token 还能验过——这是 07 章「密钥轮换」能不停机的前提。
忽略了kid、只用「当前唯一那把密钥」验签的应用,换密钥当天会全体掉线。
两把 key:签名与加密是两件事
同一个 realm 的 JWKS 里有两把密钥:
kid = 6sKOrtKwkpycDAQbwZzpRKJEE04b5-Zy3wCoCUI0ff4 | kty = RSA | alg = RS256 | use = sig
kid = Wm7uV1Y2D6Xz1nADFcVsnGmcQWkOCvw39w-bOeYWhjo | kty = RSA | alg = RSA-OAEP | use = enc
use = sig的那把用来签名,ID Token 头部的kid必须指向它;use = enc的那把用来加密(JWE),当应用要求「ID Token 也要加密」时才会用到。
这两把是独立的,因为它们的生命周期与泄露后果不同:签名密钥泄露 = 谁都能伪造身份;
加密密钥泄露 = 历史令牌可被解开。轮换策略因此也不同。
见到 JWKS 里有多把 key 时,先看 use 再看 alg,不要以为它们是一个密钥的两种表示。
四、六项校验:每一项漏了会出什么事
应用拿到 ID Token 之后要做的事,是把 02 章埋下的钉子逐个拔掉。这六项缺一不可:
| 校验 | 具体做什么 | 漏了会出什么事 |
|---|---|---|
| 签名 | 按 kid 取密钥,验第三段 |
任何人能手写一个 {"sub": "别人的 id"} 并通过 |
iss |
与配置里的 issuer 字面量比对 | 换过地址、加过代理后可能把别的签发者当自己人(05 章) |
aud |
必须包含自己的 client_id |
一个签给别的应用的 ID Token 能在你这里当登录成功 |
exp(含容差) |
未过期;容差窗口用来吸收时钟偏移 | 一个很久以前签发的 token 可以无限期复用 |
nonce |
与本次登录发出的值比对 | 重放攻击:把一次旧登录的 ID Token 塞进新会话 |
at_hash |
与本次的 access token 摘要比对 | 攻击者可以把 A 的 access token 配 B 的 ID Token 拼起来用 |
再加两项实践里同样重要的:
| 校验 | 作用 |
|---|---|
typ |
确认你拿到的是 ID 而不是 Bearer——把「身份结论」与「访问凭据」分开 |
azp |
当 aud 有多个值时,指明是谁发起的这次请求 |
每个 claim 各自防什么
nonce 防「拿一个旧 ID Token 冒充这次登录」
at_hash 防「ID Token 与 access token 被拆开重新拼接」
sid 会话标识:登出、back-channel 登出、会话管理都靠它定位「是哪一次登录」
auth_time 真正的认证发生时刻(刷新时 iat 会变,auth_time 不变)
acr 认证上下文(例如是否多因素),用来表达「这次认证有多强」
jti 这个令牌的唯一 id,用于去重/撤销记录
iat 与 auth_time 的差别值得单独记住:刷新之后 iat 会更新、auth_time 不会。
把 iat 当「登录时间」会在用了刷新令牌之后变成错的;
而很多「超过 N 分钟要求重新进多因素」的策略,判据恰恰是 auth_time。
五、access token:该给前端吗?该塞角色吗?
这两问没有「标准答案」,但有可以摆出来的代价。
给不给前端
| 做法 | 好处 | 代价 |
|---|---|---|
| 只放在服务端(后端持有,前端只在 cookie 里带会话) | 前端脚本读不到它 | 每个请求都要经后端代理;跨域/CSRF 要单独处理 |
| 交给前端(内存变量) | 前端可以直接调 API | 刷新页面就丢;要被 XSS 打就是一个窗口期的钥匙 |
交给前端并持久化(localStorage) |
体验最好 | 任何脚本都能读走;这是最常见的一类真实事件 |
判断顺序建议先问一句:这个令牌泄露后,攻击者能做什么、能持续多久?expires_in = 300 意味着窗口只有 5 分钟;但如果同时给了 refresh token,
攻击者能一直续期——两者的暴露面是叠加的,要一起看。
塞不塞角色
本课主案例的 access token 里带着授权信息:
"realm_access": { "roles": ["offline_access", "default-roles-sso-lab", "uma_authorization"] },
"resource_access": { "account": { "roles": ["manage-account", "manage-account-links", "view-profile"] } }
| 做法 | 好处 | 代价 |
|---|---|---|
| 角色放进令牌(自包含) | 资源服务器不用回调 IdP,判断快、依赖少 | 撤权不即时:令牌过期前旧角色一直有效;令牌越来越长 |
| 每次去问 IdP / 查本地位 | 撤权即时 | 多一次网络依赖;要处理 IdP 短暂不可用时的降级策略 |
注意这是一个取舍,不是「哪一种更先进」。真正要写进设计文档的是这两句话:
「这个系统的撤权时限是多少」 与 「IdP 不可用时,接口是拒绝还是降级」。
动手:可观察结果
cd courses/identity/sso-engineering/course/project
python examples/01_authorization_code_flow.py
python examples/04_bad_signature.py
要观察、并且能用本节术语解释的四件事:
id_token aud = sp-spa而access_token aud = account——同一个用户、同一时刻,
两种aud。这就是「先看aud再判断手里是什么」的依据。- ID Token 的
at_hash与 access token 绑定:把 access token 换掉但留着原来的 ID Token,
校验就会失败(示例 04 的丙段)。 - 改 payload、签名段不动 →
signature mismatch;把kid换成一个没登记过的 →
取密钥这一步就被拒。两种改法断在不同位置。 verify_id_token里六项校验的顺序与失败信息——这就是本节那张表的代码版本。
故障注入
| 破坏 | 改法 | 预期现象 | 对应哪一项校验 |
|---|---|---|---|
改 payload 的 sub |
python examples/04_bad_signature.py |
signature mismatch |
签名 |
换 kid |
同上,乙段 | unknown kid: lab-key-v9 |
签名(选钥匙那一步) |
| 改 access token | 示例 04 丙段 / 测试 test_tampered_access_token_fails_at_hash |
at_hash does not match access_token |
at_hash |
换个 client_id 配置去验同一个 ID Token |
把 RP 的 client_id 改成 sp-portal 后验 sp-spa 的 token |
aud does not include this client |
aud |
把时钟往前推过 exp |
测试里 op.clock.advance(400) |
exp passed |
exp |
最后一条要注意别用「把容差开大」来绕过:容差是为时钟偏移留的,
开成几分钟就等于把「令牌有效期」偷偷延长了几分钟(本课实验台把它做成MiniRP(..., leeway=60) 这样显式的参数,正是为了让这个决定写在明面上)。
生产边界
| 教学里这样 | 真实部署要换成 | 不换会出什么事 |
|---|---|---|
| HS256 对称签名(标准库没有现成 RSA) | RS256 / ES256 非对称 + 公布 JWKS | 校验密钥 = 签发密钥:任何持有它的组件都能伪造身份 |
| 密钥写死在代码里 | 密钥托管 + 定期轮换,旧密钥按 kid 保留一段时间 |
换密钥当天所有在途 token 全部验不过 |
| JWKS 每次现取 | 缓存 JWKS,并按 kid 未命中时限流刷新 |
缓存太久则轮换后验不过;完全没缓存则被 JWKS 端点拖住、还可能被打 |
expires_in = 300、refresh_expires_in = 1800 当定值 |
按业务与暴露面校准,并明确「刷新令牌存哪」 | 寿命过长 = 泄露窗口过长;过短 = 刷新风暴 |
| 令牌里塞角色 | 明确撤权时限;必要时改为资源服务器侧查询或引入撤销机制 | 撤权要等到令牌过期才生效,安全事件里这段时间最难受 |
| ID Token 明文(JWS) | 按需启用 JWE 加密(JWKS 里 use = enc 的那把) |
令牌会经过浏览器,明文 claim 全部暴露给端上脚本 |
| 只验签名 | 六项校验齐全 + typ / azp |
签名只证明「是 IdP 签的」,不证明「这次登录合法、这个 token 是给你的」 |
自测题
- 手上有两个 token,
aud一个是sp-spa、一个是account。哪个是 ID Token?为什么? nonce与at_hash都在 ID Token 里,它们防的攻击有什么不同?只做nonce、不做at_hash,
攻击者能构造出什么?- 为什么
auth_time与iat会在刷新之后分叉?一个「登录 30 分钟后要求重新做多因素」的策略
应该用哪一个? - JWKS 里有
use = sig与use = enc两把 key。什么情况下 ID Token 会用到第二把? - 一个系统把角色放进令牌、有效 1 小时。有人被撤权之后,最坏情况下还能用多久?
说出两个能让这个时间变短的做法,以及各自的代价。
现在能解释什么
- 三种 token 的归属方、
aud与typ的差别,以及为什么不能凭「都叫 token」判断用途; - JWT 三段里哪一段是编码、哪一段是安全边界,
kid为什么是轮换的前提; - 六项校验各自防什么、漏掉会出什么事,以及
nonce/at_hash/sid/auth_time的分工; - access token 该不该交给前端、角色该不该塞进令牌——两问都要用「暴露面」和「撤权时限」来回答,
而不是用「哪种更先进」; - 为什么「签名验过了」离「这次登录合法」还差五项检查。