令牌被偷了也白搭:说清楚 PoP 与证书绑定令牌

Posted on 六 01 8月 2026 in Tech

Abstract 令牌被偷了也白搭:说清楚 PoP 与证书绑定令牌
Authors Walter Fan
Category learning note
Status v1.0
Updated 2026-08-01
License CC-BY-NC-ND 4.0

先讲个让人后背发凉的小事。

某次排查线上问题,我在一份抓包日志里看到一整串 Authorization: Bearer eyJhbGci...。当时脑子里冒出的第一个念头不是“哦,这是访问令牌”,而是——如果这份日志被谁复制走了,他现在就能拿着这串字符,冒充这个用户去调 API,而且服务端根本分不出来。

这不是我一个人的担忧。OAuth 里最常见的 Bearer Token(持有者令牌)有一句很扎心的定义:

Bearer = Whoever Bears It Wins。谁拿到,谁就赢。

令牌本身不认人。它就像一张不记名的电影票,谁攥在手里谁就能进场。这篇文章想聊清楚的,就是业界怎么给这张“不记名票”加上一道“认人”的锁——两个关键词:PoP(Proof of Possession,持有证明)Certificate-Bound Token(证书绑定令牌)

一句话先把结论立住:

  • PoP 回答的是“你能不能证明你是令牌的主人”。
  • Certificate-Bound Token 是 PoP 的一种具体实现,答案是“这张令牌只认那把证书私钥”。

提示:文中的 JWT、mTLS、cnf、DPoP 这些词,第一次出现我都会用一句大白话解释。不写后端的读者也能顺下来,代码块可以跳过。


1. 先看清楚 Bearer Token 的死穴

咱们把普通 OAuth 的流程摊开看。授权服务器发给客户端一个访问令牌,客户端调 API 时把它塞进请求头:

Authorization: Bearer eyJhbGci...

服务端的验证逻辑简单得可怕,基本就一句话:这令牌是真的、没过期?是的话,放行。

问题就出在这里——它只验令牌本身,从不问“拿令牌的人是不是当初那个人”。于是攻击者的活儿变得毫无难度:

sequenceDiagram
    participant C as 客户端
    participant A as 攻击者
    participant API as 资源服务器
    C-->>A: 令牌泄露(日志/抓包/XSS)
    A->>API: Authorization: Bearer xxxxx
    API-->>A: 令牌有效 → 放行 ✅

攻击者不需要知道密码,不需要碰你的账号,甚至不需要理解令牌里写了什么。他只要复制那串字符,就赢了。

这就是 RFC 6750 定义的 Bearer Token 的本质。它简单、通用、到处都在用,但它的安全模型建立在一个脆弱的假设上:这串字符从签发到使用,中间不会被任何人看到。 现实里,日志、代理、浏览器插件、缓存,任何一个环节都可能把这个假设击穿。

Bearer Token 像不记名的现金:方便,但丢了就是别人的。


2. PoP:光有令牌不够,还得证明你是主人

PoP 的思路特别朴素。服务端不再只问一个问题,而是问两个:

  1. 令牌对不对?
  2. 你能不能证明你就是这令牌的主人?

两个都过才放行,缺一不可。怎么“证明”?靠一把只有主人自己有的私钥

它把不记名的现金,换成了记名的银行卡:卡(令牌)你可以给别人看,但没有密码(私钥),别人刷不了。

flowchart TD
    Start[收到请求] --> T{令牌有效?}
    T -- 否 --> Reject[拒绝]
    T -- 是 --> P{能证明持有对应私钥?}
    P -- 否 --> Reject
    P -- 是 --> OK[放行 ✅]

关键在于第二道关。即使攻击者偷到了令牌那串字符,他手里没有私钥,签不出正确的签名。服务端一看签名对不上,直接 403。令牌到了他手里,也只是一串废字符。

PoP 的基本流程

拆开看,一次 PoP 大致是这样跑的。客户端手里有一对钥匙:私钥自己藏好,公钥可以公开。

申请令牌时,授权服务器把客户端的公钥指纹记在令牌里,放在一个叫 cnf(confirmation,确认声明)的字段里:

{
  "sub": "alice",
  "cnf": { "...": "客户端公钥(或其指纹)" }
}

以后每次调 API,客户端不光带上令牌,还要用私钥对“这次请求的关键信息”签个名——比如 HTTP 方法、URI、时间戳:

GET /orders
Authorization: PoP <token>
Signature: Sign(Method + URI + Timestamp)

资源服务器收到后,从令牌里取出公钥,用公钥去验这个签名。对得上,说明请求确实是私钥持有者本人发的;对不上,拒绝。

sequenceDiagram
    participant C as 客户端(有私钥)
    participant AS as 授权服务器
    participant RS as 资源服务器
    C->>AS: 申请令牌(附带公钥)
    AS-->>C: 令牌(cnf=公钥)
    C->>RS: 令牌 + 用私钥签的请求签名
    RS->>RS: 从令牌取公钥, 验签名
    RS-->>C: 签名对 → 放行 ✅

签名里带上时间戳还顺手解决了另一个问题:重放攻击。攻击者就算录下了你某一次完整的请求,过一会儿再发出去,时间戳也已经过期了。


3. Certificate-Bound Token:把令牌钉死在一张证书上

PoP 是个思路,落地时“用什么当私钥”有好几种选择——可以是软件里的一对公私钥,也可以是 TPM、Secure Enclave、HSM、FIDO Key 这类硬件里的密钥。

Certificate-Bound Token(证书绑定令牌)是 PoP 的一种具体实现,它绑定的是 X.509 证书。 X.509 就是我们平时说 HTTPS 证书时的那个证书标准,浏览器地址栏那把小锁背后就是它。

这套玩法通常和 mTLS(双向 TLS) 配合。普通 HTTPS 里,只有服务器要出示证书证明“我是这个网站”;mTLS 则要求客户端也出示自己的证书,双方互相验明正身。客户端手里握着自己的 client.crt(证书)和 client.key(私钥)。

绑定的过程是这样的:授权服务器在发令牌时,把客户端证书的 SHA-256 指纹写进令牌:

{
  "iss": "https://issuer",
  "sub": "alice",
  "exp": 123456789,
  "cnf": {
    "x5t#S256": "6F4A...证书的 SHA-256 指纹"
  }
}

这里的 cnf 还是那个确认声明(RFC 7800 定义),里面的 x5t#S256 专指“证书的 SHA-256 指纹”。相当于在令牌里刻了一句话:这张票只认指纹为 6F4A... 的那张证书。

以后客户端调 API 时,资源服务器分三步核对:

flowchart TD
    A[从 mTLS 拿到客户端证书] --> B[计算证书指纹]
    B --> C[读取令牌里的 cnf.x5t#S256]
    C --> D{两个指纹一致?}
    D -- 一致 --> OK[放行 ✅]
    D -- 不一致 --> Reject[拒绝 ❌]

妙就妙在:攻击者偷到令牌那串字符没用。因为他手里没有 client.key,连 mTLS 握手都完不成——TLS 通道根本建立不起来,请求发都发不出去,更别说验指纹了。

为什么叫“绑定”

因为令牌和证书之间被拴成了一对:

flowchart LR
    Token[访问令牌] -->|只能配合| CertA[证书 A]
    Token -.->|换成| CertB[证书 B] -.-> X[失效]
    Token -.->|没有证书| None[失效]

换一张证书、或者干脆没证书,令牌立刻作废。所以叫它“证书绑定令牌”。

令牌里还能绑公钥

cnf 不止能装证书指纹。如果你走的是纯公钥绑定的路线,里面装的就是一个 JWK(JSON Web Key,公钥的 JSON 表示):

"cnf": {
  "jwk": { "...公钥内容..." }
}

装证书指纹,是证书绑定;装公钥,是下面要说的 DPoP 那一路。骨架都是 cnf,区别只在“往里塞什么”。


4. PoP 的几种主流实现,一张表看懂

把前面的东西收进一张表,一眼就能定位:

类型 绑定对象 需要 mTLS 吗 典型场景
Bearer Token 最常见,但谁拿到谁能用
Certificate-Bound Token X.509 证书 通常需要 企业内网、金融、服务间通信、零信任
DPoP JWK 公钥 浏览器、移动端、SPA 等公网客户端
Token Binding(已废弃) TLS Channel 基本没人用了

它们其实是一层套一层的关系,画出来是这样:

flowchart TD
    B[Bearer Token] --> B1[谁拿到都能用<br/>最常见]
    B --> P[PoP Token]
    P --> C[Certificate-Bound<br/>绑证书]
    P --> D[DPoP<br/>绑公钥]
    P --> TB[Token Binding<br/>已淘汰]

目前 OAuth 社区最推荐的是两条路:

  • mTLS + Certificate-Bound Token——安全性高,适合服务与服务之间的通信(双方都有证书,天然适配)。
  • DPoP——不需要 mTLS,更适合公网客户端(浏览器、App 里没法优雅地管理客户端证书,用一对临时公私钥更现实)。

选哪条,基本就看你的客户端是“机房里的服务”还是“用户手机/浏览器”。


5. 别把 mTLS 和证书绑定令牌搞混

这两个词长得像,很多人会当成一回事,其实它们各管一摊:

  • mTLS 负责认证:证明客户端是谁(TLS 握手时验证书)。
  • Certificate-Bound Token 负责绑定:证明这张令牌就是发给这张证书的(验 cnf 指纹)。

只有 mTLS,你知道对面是谁,但不知道他手里的令牌是不是“借来的”;只有证书绑定而没有真正的 mTLS 通道,那指纹也无从核对。两个配合起来,才形成完整闭环:

sequenceDiagram
    participant C as 客户端
    participant API as 资源服务器
    C->>API: mTLS 握手(出示客户端证书)
    API->>API: ① mTLS 确认客户端身份
    API->>API: ② 计算证书指纹
    API->>API: ③ 读令牌 cnf,比对指纹是否一致
    API-->>C: 三步都过 → 放行 ✅

结论很干脆:就算攻击者同时偷到了令牌、又能连上 API,只要他没有那把私钥,令牌照样用不了。 安全性从“保护一串字符不泄露”,升级成了“保护一把私钥不泄露”——而私钥,是可以放进硬件、永不导出的。


6. 那 JWT header 里的 kid 能不能顶上?

这是我被问过好几次的问题,也是个特别值得澄清的误会:“既然 JWT 头里有个 kid,能标识 key,那它是不是也能防盗用?”

答案很干脆:不能,它俩根本不在一个赛道上。

先看 kid 到底是干嘛的。JWT 是用 JWS(JSON Web Signature)签名的,它的头部长这样:

{
  "alg": "RS256",
  "kid": "2026-key-01"
}

按 RFC 7515 的原话,kid(Key ID)只是一个 hint(提示),告诉验签方“我这个令牌是用哪把 key 签的,你去对应的 key 里挑那把来验”。它解决的是签发方的一个纯工程问题:密钥轮换

打个比方:授权服务器同时挂着好几把签名私钥(旧的还没退役、新的已经上线),它把公钥集合发布在一个 JWKS(JSON Web Key Set)地址上,每把公钥带一个 kid。验签方拿到令牌,读一眼 kid,就知道该用 JWKS 里的哪把公钥来验。仅此而已。

看出关键区别了吗?

kid cnf(PoP / 证书绑定)
回答的问题 该用哪把签名 key验签? 令牌是不是你这个客户端的?
关联的私钥 签发方(授权服务器) 客户端自己的
客户端要不要出示什么 不用 必须用私钥签名 / 出示证书
攻击者偷到令牌后 照样能用 ❌ 用不了 ✅

一句话戳破:

kid 说的是“这令牌是谁签的”,cnf 说的是“这令牌是发给谁的”。前者关心签发方的钥匙,后者关心持有者的钥匙。

再往细里说,kid 关联的是授权服务器的签名私钥——那把私钥根本不在客户端手里,客户端从头到尾不需要用它证明任何东西。所以攻击者偷到一个带 kid 的普通 Bearer JWT,kid 帮他顺利通过验签(因为令牌确实是真的),他照样能冒充你。kid 不但没拦住盗用,反而是在"帮"验签方确认这令牌是真货。

顺便说一句,RFC 7515 头部里还有几个长得像的字段容易一起混:jwk 是直接把签名用的公钥塞进头里、x5t#S256签名证书的 SHA-256 指纹。注意这个 x5t#S256 和第 3 节讲的客户端证书绑定里的 x5t#S256 位置不同、含义不同——头部里的说的是"签名用的证书",cnf 里的说的才是"绑定的客户端证书"。同一个词,放错地方意思就变了。

所以别指望 kid 防盗用。它是密钥轮换的好帮手,但持有证明这活儿,还得靠 cnf + 私钥。


7. 跟 Kubernetes、SPIFFE、微服务是什么关系

如果你在搞云原生,这套东西不是屠龙之技,而是每天打交道的现实。对照几个常见组件:

  • Kubernetes ServiceAccount JWT:默认就是 Bearer Token。Pod 里挂载的那个令牌,拿到就能用(在有效期内)。方便,但一旦被带出容器,风险和前面说的一模一样。
  • SPIFFE / SPIRE:给工作负载(workload,可以理解成“一个跑着的服务实例”)发身份。它的两种形态里,X.509 SVID(短生命周期证书)天生就适合和 mTLS 配对;另一种是 JWT-SVID。
  • 服务网格(如 Istio):通常用 mTLS 在服务之间建立加密通道,再叠加身份策略做细粒度授权。
  • 如果授权服务器签发的是 Certificate-Bound Token,那么即使访问令牌泄露,攻击者没有那个工作负载持有的私钥,也没法冒充它去调下游 API。

把它们串起来,就是现代零信任架构里越来越常见的一套组合拳:

flowchart TD
    ID[SPIFFE X.509 身份] --> TLS[mTLS 通道]
    TLS --> AS[授权服务器<br/>签发 Certificate-Bound Token]
    AS --> RS[资源服务器]
    RS --> V1[✓ 令牌有效]
    RS --> V2[✓ mTLS 证书匹配 cnf]
    RS --> V3[✓ 授权策略允许]

这套设计一口气回答了三个不同层面的问题,别把它们混为一谈:

  • 身份认证(Who are you?)——你是谁,靠 mTLS 证书。
  • 令牌防盗用(Can you prove you own it?)——这令牌是不是你的,靠 PoP / 证书绑定。
  • 授权控制(What are you allowed to do?)——你能干什么,靠授权策略。

金融、云原生、零信任系统里,这三层正越来越多地被当成“标配”而不是“加分项”。


最后一句

回到开头那份让我后背发凉的抓包日志。如果那串令牌是证书绑定的,我大概就能松口气了——因为光有那串字符没用,得有对应的私钥才行,而私钥不在日志里。

Bearer Token 教会我们方便,PoP 和证书绑定教会我们:方便和安全之间,差的往往就是一把“别人偷不走”的钥匙。

行动清单

想在自己的系统里往前挪一步,可以按这个顺序:

  1. 先盘点:你的哪些令牌是纯 Bearer?尤其注意会进日志、进浏览器、进第三方代理的那些。
  2. 分场景选路线:服务间通信优先 mTLS + Certificate-Bound Token;公网客户端(浏览器/App)优先 DPoP
  3. 令牌有效期尽量短:PoP 提高了盗用门槛,但短有效期是永远不亏的兜底。
  4. 私钥托付给硬件:能进 TPM / Secure Enclave / HSM 的私钥,就别留在配置文件里。
  5. 服务端务必校验 cnf:签发了绑定令牌却不核对指纹,等于装了锁不上锁。
  6. 别自己造密码学:签名、指纹、TLS 这些,用成熟库和标准(RFC 6750 / 7800 / 8705 / 9449),别手搓。

一句话收尾:

Bearer 是“不记名的票”,PoP 是“记名的票”。当你的系统开始值钱,就该把票记上名了。

参考资料

想深挖的,直接读一手 RFC,别只看二手博客(包括本文):

令牌与持有证明

JWT / JWS / JWK(理解 kid 必读)

工作负载身份(云原生场景)

全文思维导图

@startmindmap
<style>
mindmapDiagram {
  node {
    BackgroundColor #F8F9FA
    RoundCorner 10
    Padding 10
    FontSize 13
  }
  :depth(0) {
    BackgroundColor #1E3A5F
    FontColor white
    FontSize 18
    FontStyle bold
  }
  :depth(1) {
    FontSize 15
    FontStyle bold
  }
  :depth(2) {
    FontSize 13
  }
}
</style>

* PoP 与证书绑定令牌
** Bearer 的死穴
*** 只验令牌不认人
*** 谁拿到谁能用
*** 日志/抓包/XSS 一泄就完
** PoP 是什么
*** 令牌对不对 + 你是不是主人
*** 靠私钥签名证明
*** 顺带防重放(带时间戳)
** Certificate-Bound Token
*** PoP 的一种实现
*** 绑 X.509 证书
*** cnf.x5t#S256 存证书指纹
*** 配合 mTLS 使用
** 几种实现
*** Bearer:无绑定
*** Certificate-Bound:绑证书,服务间
*** DPoP:绑公钥,公网客户端
*** Token Binding:已废弃
** mTLS vs 证书绑定
*** mTLS 管认证:你是谁
*** 证书绑定管防盗:令牌是不是你的
*** 二者配合才闭环
** kid 能否防盗用
*** 不能,不同赛道
*** kid=用哪把签名key的提示
*** 关联签发方私钥,非客户端
*** RFC7515:kid 只是 hint
** 云原生里的位置
*** K8s SA JWT 默认是 Bearer
*** SPIFFE X.509 SVID 配 mTLS
*** Istio 用 mTLS 建通道
*** 零信任三层:认证/防盗/授权
@endmindmap

PoP 与证书绑定令牌 思维导图


本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。 欢迎在我的个人网站 https://www.fanyamin.com 访问原文并评论。