🏠 总目录📚 本教程 08e · JWT
📑 本页目录(点开跳转)

08e · JWT:三段结构、四种绕过,和吊销这件难事

60 分钟 | ⭐ 几乎所有 JWT 漏洞都长一个样:让 token 自己决定该怎么验它自己


🎯 一句话

JWT 的载荷不是加密的、只是编码的;而它的头部里写着「该用哪个算法验我」—— 你的验证代码一旦信了那一行,攻击者就能自己给自己签一张票。 上一章 08d · 把登录外包出去 走完了授权码流程,在第 8 步换回了一张 token。这一章讲这张票本身:它长什么样、它被绕过的四种方式、以及签出去之后想收回来有多难。⭐ 没读过上一章也能直接看 —— 这一章只需要你知道「有个签发方给了你一张票」。


🧾 一、JWT 的三段

第 8 章说过 ⚠️ 「payload 只是 base64、不是加密」。这一节把它变成你能亲手验证的东西 —— JWT 就是 头部.载荷.签名,三段都是 base64url,用点号连起来:

# 手工拆开一个 JWT —— 不需要密钥,也不需要任何第三方库
import base64, json

TOKEN = ("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
         ".eyJzdWIiOiJ1XzQyIiwidGlkIjoiYWNtZSIsInJvbGUiOiJ1c2VyIiwiZXhwIjoxNzY3MjI1NjAwfQ"
         ".XHv6jpLDce_o34SAysGAdayLTPbNi1Laap5agXqsffE")


def b64d(seg):
    # ⭐ 是 base64url,而且结尾的 = 填充被去掉了,解码前要补回来
    return base64.urlsafe_b64decode(seg + "=" * (-len(seg) % 4))


h, p, s = TOKEN.split(".")
print("头部:", json.loads(b64d(h)))
print("载荷:", json.loads(b64d(p)))       # ⭐ 全程没有密钥,谁捡到 token 谁就能读
print("签名:", len(b64d(s)), "字节 —— 它证明「没被改」,不证明「没被看见」")

实跑输出:

头部: {'alg': 'HS256', 'typ': 'JWT'}
载荷: {'sub': 'u_42', 'tid': 'acme', 'role': 'user', 'exp': 1767225600}
签名: 32 字节 —— 它证明「没被改」,不证明「没被看见」

这段代码里没有任何密钥。 载荷不是被加密的,只是被编码了。⚠️ 所以里面绝不能有手机号、身份证、内部备注、别家租户的 id。常用字段:sub(说的是谁)、iss(谁签的)、aud(签给谁用的)、exp(何时作废)、iat(何时签的)、jti(这张票的唯一编号,吊销时用得上)。

⚠️ 还有个体积问题:JWT 每个请求都要带。往里塞整份权限清单,就把它变成了每个请求的固定开销,还可能撞上反向代理对请求头大小的限制 🗓️。⭐ 判据:payload 只放「路由和鉴权当场要用」的最小集合,别把它当缓存使。


💀 二、被绕过的那四种方式

⭐⭐ 这是本章最值钱的一节。 先给总纲,后面每一种都是它的变体:

⭐⭐ 几乎所有 JWT 漏洞都长一个样:让 token 自己决定该怎么验它自己。 头部里的 algkid 都是攻击者可以随便写的字段,验证逻辑一照着它们走,就等于让嫌疑人自己出具证词。

下面这段能直接跑,同时演示前两种绕过。⚠️ 里面那个用纯整数运算写的玩具 RSA 只为让这段不依赖第三方库,别照抄 —— 《密码学》第 14 章的教训正是数学写对了,实现照样能漏

# 💀 两种经典绕过:alg:none 与 算法混淆(RS256 当 HS256 用)
import base64, hashlib, hmac, json

# ⚠️ 教科书 RSA,只为让这段能用纯标准库跑起来;真实项目绝不自己实现签名
P, Q = 954780363788335820345537266331143731120811, 1192651600822816258890983000462819179490453
N, E = P * Q, 65537
D = pow(E, -1, (P - 1) * (Q - 1))                 # 私钥:只有签发方有
PUB = ("%d:%d" % (N, E)).encode()                 # ⭐ 公钥:JWKS 端点上谁都下得到


def b64e(raw): return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
def b64d(seg): return base64.urlsafe_b64decode(seg + "=" * (-len(seg) % 4))
def head(h, p): return b64e(json.dumps(h).encode()) + "." + b64e(json.dumps(p).encode())
def dg(si): return int.from_bytes(hashlib.sha256(si.encode()).digest(), "big")


def verify_naive(tok):
    """💀 反面教材:用哪个算法,由 token 自己的 header 说了算。"""
    h, p, s = tok.split(".")
    alg, si = json.loads(b64d(h))["alg"], h + "." + p   # ⚠️ 攻击者写什么这里就信什么
    if alg == "none":                                   # 💀 绕过一
        return json.loads(b64d(p))
    if alg == "RS256":
        ok = pow(int.from_bytes(b64d(s), "big"), E, N) == dg(si)
    else:                                               # 💀 绕过二:公钥被当成 HMAC 密钥
        ok = hmac.compare_digest(b64d(s), hmac.new(PUB, si.encode(), hashlib.sha256).digest())
    if not ok:
        raise ValueError("签名不对")
    return json.loads(b64d(p))


si = head({"alg": "RS256"}, {"sub": "u_42", "role": "user"})          # 服务端正常签发
print("① 正常签发   →", verify_naive(si + "." + b64e(pow(dg(si), D, N).to_bytes(35, "big"))))

print("② alg:none  →", verify_naive(head({"alg": "none"}, {"sub": "u_42", "role": "admin"}) + "."))

si = head({"alg": "HS256"}, {"sub": "u_42", "role": "admin"})         # 拿公钥当 HMAC 密钥重签
print("③ 算法混淆   →", verify_naive(si + "." + b64e(hmac.new(PUB, si.encode(), hashlib.sha256).digest())))

实跑输出:

① 正常签发   → {'sub': 'u_42', 'role': 'user'}
② alg:none  → {'sub': 'u_42', 'role': 'admin'}
③ 算法混淆   → {'sub': 'u_42', 'role': 'admin'}

user 变成了 admin,两次,都没有私钥。

alg: none —— 规范里留这个值,本意是给「完整性已由别的层保证」的场景用。验证代码照着 alg 分支走,攻击者把头部改成 none第三段留空,就能随便改载荷。

② 算法混淆 —— 更阴,因为它绕过的不是「有没有验」,而是「用什么验」:服务端本意是 RS256(私钥签、公钥验,而公钥挂在签发方(IdP)的 JWKS 端点上谁都能下载);攻击者把头部改成 HS256(对称的 HMAC),拿那串公开的公钥当 HMAC 密钥去签;服务端手上只有一份「密钥材料」,代码写成 decode(token, key) 却没钉死算法,于是老老实实拿公钥当 HMAC 密钥算了一遍 —— 当然对得上。

根因一句话:非对称算法里「验证用的东西是公开的」,对称算法里「验证用的东西必须保密」,把同一份材料在两种语义之间切换就出事了。

⭐ 顺带一提,《密码学》第 11 章讲过 MAC 是对称的、验证者自己也能伪造,所以不提供不可否认性 —— 这正是「多方都要验签时该用 RS256 而不是 HS256」的理由。

kid 注入 —— kid(key id)是头部里的一个指针,告诉服务端「这张票该用哪把钥匙验」,多把密钥轮换时必需。危险在于它是攻击者可控的字符串,而很多实现拿它去拼东西:拼文件路径(指向一个内容可预测的公共文件,攻击者就知道密钥是什么了)、拼 SQL(那就是个标准注入点,可以让查询返回攻击者指定的密钥)。

判据:kid 只能当「查一张服务端自己维护的表」的键,绝不能进路径拼接、也绝不能进 SQL 拼接。 查不到就直接拒,不要「回退到默认密钥」。

④ 不校验 exp / aud / iss —— 验签通过 ≠ 这张票能用。三个字段各挡一件事,其中 aud 最容易漏

# ✅ 验签之外该查的三件事;顺带看看「不查 aud」会漏成什么样
import base64, hashlib, hmac, json, time

SECRET = b"idp-signing-key"          # 同一个签发方,一把密钥,签给好几个服务
MY_ISS, MY_AUD = "https://idp.example", "api://chat"


def b64e(raw): return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
def b64d(seg): return base64.urlsafe_b64decode(seg + "=" * (-len(seg) % 4))


def issue(payload):
    si = b64e(json.dumps({"alg": "HS256"}).encode()) + "." + b64e(json.dumps(payload).encode())
    return si + "." + b64e(hmac.new(SECRET, si.encode(), hashlib.sha256).digest())


def verify(tok, check_aud=True):
    h, p, s = tok.split(".")
    if json.loads(b64d(h)).get("alg") != "HS256":       # ⭐ 算法白名单:服务端定,不看 token
        raise ValueError("算法不在白名单")
    want = hmac.new(SECRET, (h + "." + p).encode(), hashlib.sha256).digest()
    if not hmac.compare_digest(b64d(s), want):          # ⭐ 等时比较,别用 ==
        raise ValueError("签名不对")
    body = json.loads(b64d(p))                          # ⭐ 验签通过之后才读载荷
    if body.get("exp", 0) <= int(time.time()):
        raise ValueError("已过期")
    if body.get("iss") != MY_ISS:
        raise ValueError("签发方不对")
    if check_aud and body.get("aud") != MY_AUD:         # ⭐ 这张票是不是发给【我】的
        raise ValueError("受众不对:%r" % body.get("aud"))
    return body


base = {"sub": "u_42", "iss": MY_ISS, "exp": int(time.time()) + 600}
other = issue(dict(base, aud="api://analytics", role="admin"))   # 同一家签给【别的服务】的票

print("① 发给我的票 →", verify(issue(dict(base, aud=MY_AUD)))["aud"])
print("② 不查 aud  →", verify(other, check_aud=False))            # 💀 签名合法,于是放行了
for n, (bad, why) in zip("③④", [(other, "查了 aud"), (issue(dict(base, aud=MY_AUD, exp=1)), "过期的票")]):
    try:
        verify(bad)
    except ValueError as e:
        print("%s %s  → 拒绝:%s" % (n, why, e))

实跑输出(exp 是「当前时间 + 600 秒」,你跑出来的数字不一样):

① 发给我的票 → api://chat
② 不查 aud  → {'sub': 'u_42', 'iss': 'https://idp.example', 'exp': 1786360633, 'aud': 'api://analytics', 'role': 'admin'}
③ 查了 aud  → 拒绝:受众不对:'api://analytics'
④ 过期的票  → 拒绝:已过期

💀 看第二行:那张票是同一个签发方签的、签名完全合法没过期,它只是本来发给另一个服务的。少查一句 aud,分析服务的票就进了聊天接口 —— 而它在自己的地盘上可能权限低得多,作者根本没想过它会被别处接受。

⭐ 三个字段的分工:exp 挡「捡到一张很旧的票」、iss 挡「另一家签发方签的票」、aud 挡「同一家签给别人的票」。⚠️ 还要注意 verify() 里的顺序 —— 验签通过之后才 json.loads 载荷;反过来写就等于把未验证的输入喂进了后面所有逻辑。

💀 最后一条历史教训《密码学》第 17 章写着 PKCS#1 v1.5 的 Bleichenbacher'06 攻击 —— 验证实现如果没有严格检查填充结构(只是「找到 hash 就通过」),e=3 时攻击者可以伪造签名,那一章明确点名它影响过 OpenSSL、Firefox 和多个 JOSE/JWT 库

⭐⭐ 这条的意思不是「JWT 不安全」,是「验签这件事连大牌库都写错过」。所以本章的最终建议只有两句: ① 用成熟库 🗓️,别自己实现验签。 ② 调用时显式传入算法白名单,密钥从你的配置来(第 13 章),不从 token 来。 这两句就把上面四种绕过里的前三种一次性关掉了。


🛑 读到这里可以停 —— 前半章讲完了(约 33 分钟)。 后半章还有:吊销:从第 8 章停下的地方接着讲 · 换个栈怎么对应 回来的时候不用重读,直接从下一节接着看就行。


🔁 三、吊销:从第 8 章停下的地方接着讲

第 8 章第三节已经点出了取舍 —— 查表式(能立刻吊销、每次请求查库)对签名自包含(不查库,但⚠️ 吊销难),并给了方向:短时效访问凭证 + 长时效刷新凭证。这里往下接,给三种解法和各自的代价。

解法 怎么做 ⚠️ 代价
① 短 exp + 刷新令牌 访问令牌分钟级,吊销只作用在刷新那一侧 留一个「最多还能再用 N 分钟」的窗口
② 黑名单 把要作废的 jti 写进一张快表,TTL 设成它的剩余有效期 每个请求多查一次 —— 自包含的卖点折损了
③ 版本号 用户表加一列 token_version 并签进载荷,改密/登出全设备时 +1 同样要查库,但能跟着「取用户信息」那次查询顺带拿回

⭐⭐ 选之前先回答一个问题:你需要多快吊销? 能接受「几分钟内失效」就只用 ①,别把系统搞复杂。如果「改密后立刻失效」是硬需求,那就得每个请求查库 ——这时候你其实已经承认「自包含」在你的场景里不成立了,⭐ 那还不如干脆退回第 8 章的查表式会话,少一层要维护的机制。

⚠️ 先排一个雷:这里说的「令牌」和第 12 章的「令牌桶」毫无关系,那是限流算法,同名不同物

刷新令牌有效期长得多,被偷的损失也大得多,标准做法是轮换 + 重放检测① 轮换 —— 每次换票时顺带发一张新的刷新令牌,旧的当场作废② ⭐⭐ 重放检测 —— 如果一张已作废的刷新令牌又被用了,只有两种可能(真用户在用副本,或有人偷了副本),你分不清是谁,但你知道这条链上有两份拷贝,⭐ 正确反应是把这次登录派生出的整个家族全部作废,强制重新登录;③ 作用域收窄 —— 刷新令牌只该被刷新端点接受,业务接口一律不认。

重放检测是整节最值得记的一条:它把「令牌被复制」这件本来完全静默的事变成了一个你能观测到的事件 —— 和第 8 章讲租户隔离时那句「不能靠记得写、只能靠写错跑不通」是同一种思路:把静默的失效变成会响的信号。

🚦 token 存在浏览器的哪里

存哪 XSS 下 CSRF 适合
HttpOnly Cookie ⭐ JS 读不到 ⚠️ 有,要配 SameSite 或 CSRF token 同域网页(第 8 章的结论)
localStorage ⚠️⚠️ 任何一段 XSS 都能整个读走并外传 无(不会自动带) 跨域 / 移动端
只放在内存变量里 攻击窗口小得多(刷新页面就没了) ⭐ 折中做法

一个好用的组合刷新令牌HttpOnly Cookie 且把 Path 限死在刷新端点上,访问令牌只放内存 —— 页面刷新后拿 Cookie 悄悄换一张新的,JS 里从来不存在长效凭证。⚠️ 这张表回答的是「存哪」;XSS 为什么能把 localStorage 整个读走、CSRF 具体怎么打,见 08c · CSRF 与 XSS


🔄 换个栈怎么对应

概念(不会过期) Python / FastAPI(本板块主栈) Node(Express / Hono) Go
校验时钉死算法 调用时显式传 algorithms=[...] 同名参数,写法不同 同名参数
取验签公钥 kid 从签发方的 JWKS 端点取 + 缓存
aud / iss 库有「期望的受众 / 签发方」这类参数,⚠️ 不传它就没得比,也就等于不查 🗓️ 同名选项 同名选项
把身份接进每个请求 Depends(current_principal)(第 8 章第四节) 中间件写进 req.user / c.set() 写进 context.Context
存刷新令牌 HttpOnly + Secure + Path 限定的 Cookie 同名属性 同名属性

这张表要看出的是:三家的库名全会过期,不过期的是那几条判据 —— 算法白名单必须显式传kid 只能当查表键aud/iss/exp 必须查验签通过之后才读载荷。换语言只换了「传参数」还是「传选项对象」这个写法。


🔗 这一章连到哪里

去哪 为什么
⭐⭐ 08d · 把登录外包出去 这张票是从哪来的:授权码流程九步、state、PKCE、OIDC。⭐ 那一章第 8 步换回来的 id_token 就是本章第一节拆开的东西
08 · 认证、会话与多租户 ⭐⭐ 它第三节末尾「查表式 vs 签名自包含、吊销难」的取舍,在本章第三节接成了三种具体解法;它那句「payload 只是 base64、不是加密」也是本章第一节的起点
08c · CSRF 与 XSS ⭐ 本章最后那张「token 存哪」的表只回答「存哪」;XSS 为什么能把 localStorage 整个读走、CSRF 具体怎么打,全在那一章
13 · 配置与密钥 ⭐ 本章的最终建议第二句「密钥从你的配置来,不从 token 来」,那个「配置」就是它 —— HMAC 的签名密钥、JWKS 的地址都归它管
16 · 上线前检查单 ⭐ 那一章给了本章的验收动作:不带 token 打业务接口要得 401用 A 的 token 请求 B 的资源 id 要得 404/403(只测「A 能拿自己的」测不出漏洞)
密码学 17 · 数字签名 ⭐⭐ 那一章写着 PKCS#1 v1.5 的 Bleichenbacher'06:验证实现不严格检查填充结构时 e=3 可伪造签名,影响过 OpenSSL、Firefox 和多个 JOSE/JWT 库 —— 本章「别自己实现验签」最硬的一条证据
密码学 11 · MAC 与认证加密 HS256 里的 HMAC 就是那一章的双层嵌套构造;⭐ 它还讲了 MAC 是对称的、验证者自己也能伪造,所以不提供不可否认性 —— 这就是多方验签该用 RS256 的理由
密码学 14 · RSA RS256 底下就是 RSA。⭐ 那一章把 JWT 列进了「RSA 仍然广泛存在」的场景,同时给了新项目的方向(签名走 Ed25519)—— 也就是说本章的 RS256 是存量而非首选

✅ 检查点

  1. JWT 的三段各是什么?为什么说「payload 只是编码不是加密」,由此得出哪条判据?
  2. 为什么不该把整份权限清单塞进 payload?(两个理由)
  3. 四种绕过各是什么?⭐ 共同的根因是哪一句?
  4. alg: none 的 token 具体怎么构造出来?
  5. 算法混淆里,服务端为什么会「老老实实对上」?根因一句话是什么?
  6. kid 的判据是什么?查不到对应的密钥时该怎么办?
  7. exp / iss / aud 各挡住哪一件事?为什么说 aud 最容易漏?
  8. verify() 里为什么必须「验签通过之后才读载荷」?
  9. 「用成熟库 + 显式传算法白名单」关掉了四种绕过里的哪几种?剩下那种要靠什么?
  10. 吊销的三种解法各付什么代价?「重放检测」发现作废的刷新令牌被重用时,正确反应是什么?
👀 答案
  1. 头部.载荷.签名,三段都是 base64url,用点号连起来。实跑那段代码全程没有任何密钥就把载荷打印出来了 —— 它只是换了个字符集,签名证明「没被改」,不证明「没被看见」。⭐ 判据:⚠️ 里面绝不能有手机号、身份证、内部备注、别家租户的 id。
  2. ① JWT 每个请求都要带,塞得越多,固定开销就越大;② 🗓️ 可能撞上反向代理对请求头大小的限制。⭐ 判据:payload 只放「路由和鉴权当场要用」的最小集合,别把它当缓存使。
  3. alg: none算法混淆kid 注入不校验 exp/aud/iss。⭐⭐ 共同根因:让 token 自己决定该怎么验它自己 —— algkid 都是攻击者可以随便写的字段,验证逻辑照着它们走,就等于让嫌疑人自己出具证词。
  4. 把头部改成 {"alg":"none"}、载荷改成你想要的(比如 role: admin),第三段直接留空(只剩末尾那个点)。规范留 none 本意是给「完整性已由别的层保证」的场景用,而照着 alg 分支走的验证代码会直接返回载荷 —— 实跑里 role 就从 user 变成了 admin
  5. 服务端本意是 RS256(私钥签、公钥验),而公钥是公开的(挂在签发方的 JWKS 端点上谁都能下载)。攻击者把头部改成 HS256,拿那串公开的公钥当 HMAC 密钥重签;服务端手上只有一份「密钥材料」,代码又没钉死算法,于是拿公钥当 HMAC 密钥算了一遍 —— 当然对得上。⭐ 根因:非对称算法里「验证用的东西是公开的」,对称算法里「验证用的东西必须保密」,把同一份材料在两种语义之间切换就出事了。
  6. kid 只能当「查一张服务端自己维护的表」的键,绝不能进路径拼接(指向内容可预测的公共文件 → 攻击者就知道密钥了)、也绝不能进 SQL 拼接(标准注入点 → 可以让查询返回攻击者指定的密钥)。查不到就直接拒绝,⚠️ 不要「回退到默认密钥」。
  7. exp 挡「捡到一张很旧的票」;iss 挡「另一家签发方签的票」;⭐ aud 挡「同一家签给别人的票」。aud 最容易漏,是因为那张票签名完全合法、iss 也对、也没过期,只是本来发给另一个服务的 —— 实跑里 api://analytics 的票在不查 aud 时直接进了 api://chat,而它在自己的地盘上可能权限低得多。
  8. 因为反过来写就等于把未验证的输入喂进了后面所有逻辑 —— 载荷在验签之前是攻击者完全可控的字符串,任何基于它的分支、查库、日志都建立在假数据上。
  9. 关掉的是前三种alg: none、算法混淆、kid 注入)—— 它们都属于「让 token 决定怎么验」,显式传白名单 + 密钥从配置来就断了这条路。⚠️ 剩下的第四种(不校验 exp/aud/iss)关不掉 —— 库没法猜你的受众是谁,你必须把期望的 aud / iss 显式传进去。
  10. exp + 刷新令牌:留一个「最多还能再用 N 分钟」的窗口;② 黑名单:每请求多查一次,自包含的卖点折损;③ 版本号:同样要查库(但能跟着取用户信息那次查询顺带拿回)。⭐⭐ 先问「你需要多快吊销」——「改密后立刻失效」是硬需求的话,不如退回第 8 章的查表式会话。重放检测发现作废的刷新令牌又被用了 → ⭐ 把这次登录派生出的整个家族全部作废、强制重新登录(你分不清是真用户还是小偷,但你知道这条链上有两份拷贝)。

🛑 可以停在这里

走神救援

上一章换回的那张票,这一章讲它本身。JWT = 头部.载荷.签名,三段都是 base64url —— 实跑那段没有密钥就把载荷打印出来了:载荷只是被编码,签名保证「没被改」不保证「没被看见」;⚠️ 别放手机号、身份证、别家租户 id,也别塞整份权限清单(每个请求都要带)。⭐⭐ 四种绕过共一个根因:让 token 自己决定该怎么验它自己 —— algkid 都是攻击者随便写的字段。① alg: none:头部改成 none签名段留空;② 算法混淆:RS256 改 HS256,拿 JWKS 上公开的公钥当 HMAC 密钥重签,服务端只有一份密钥材料又没钉死算法,当然对得上 —— 实跑里 ①② 都把 role 变成了 admin全程没有私钥;⭐ 根因是「非对称里验证材料是公开的、对称里必须保密」。③ kid 注入:⭐ 它只能当查表的键,绝不进路径或 SQL 拼接,查不到就拒、不回退默认密钥。④ 不查 exp/iss/audexp 挡旧票、iss 挡别家签的票、⭐ aud同一家签给别人的票 —— 实跑里 api://analytics 的票不查 aud 就进了 api://chat;⚠️ 顺序也是判据:验签通过才读载荷。⭐ 结论两句:用成熟库(Bleichenbacher'06 影响过多个 JOSE/JWT 库)、显式传算法白名单、密钥从配置来 —— 关掉前三种,第四种得你自己把 aud/iss 传进去。吊销三解法:短 exp + 刷新令牌 / 黑名单 / 版本号,⭐⭐ 先问「你需要多快吊销」,要立刻失效就得每请求查库,那不如退回第 8 章的查表式会话。刷新令牌轮换 + ⭐ 重放检测(作废的票又被用=链上有两份拷贝,整个家族全作废)。存哪:⭐ 刷新令牌进 Path 限死的 HttpOnly Cookie、访问令牌只放内存

下一节 👉 09-最小可用前端.md

打卡记录保存在你的浏览器里,首页能看到总进度