DPoP存储悖论:为什么基于浏览器的持有证明仍然是一个未解决的问题

你的安全团队刚刚完成了 DPoP 集成:私钥以不可导出CryptoKey对象的形式存放在 IndexedDB 中,调用exportKey()会直接抛出异常,原始密钥字节无法离开浏览器。整套流程完全通过了审计检查,直到一名渗透测试人员植入 XSS 载荷,利用你这套“受保护”的密钥签发任意的 DPoP 证明,一路无障碍地通过了资源服务器的校验。自始至终密钥从未被导出,但攻击者根本无需导出就完成了攻击过程。

这一场景直击 OAuth 2.0 最新的发送方约束机制(sender-constraining mechanism)的核心矛盾,下文将对其进行展开剖析。DPoP 完全遵循规范运行,Web Crypto API 也严格按照标准工作,但在浏览器环境下,大多数团队以为二者结合能带来的安全保障,实际上并不存在。

从 Bearer 令牌到持有证明(Proof-of-Possession)

Bearer Token 存在众所周知的隐患,那就是,任何人拿到令牌都可以直接使用。日志泄露、恶意浏览器插件、XSS、开放重定向链路等多种泄露途径,都让令牌被盗用的风险远高于合理水平。

IETF 在 2025 年 1 月发布的RFC 9700(OAuth 2.0 安全最佳现行实践更新),将发送方约束令牌(sender-constrained token)列为防范被盗令牌滥用的推荐方案。DPoP(RFC 9449,2023 年 9 月)则是应用层的实现方案,让浏览器客户端无需依赖双向TLS(mTLS)或传输层令牌绑定,就能实现发送方约束,抵御令牌盗用与重放攻击。

双向 TLS 在传输层实现了发送方约束,但需要客户端证书基础设施,而浏览器并没有向应用层开放该能力以用于令牌绑定。HTTP 令牌绑定(HTTP Token Binding,RFC 8471)是另一套传输层标准,但Chrome在2018年末移除了对它的支持,其他主流浏览器也从未适配,该标准对浏览器客户端实际已形同虚设。

而 DPoP 完全工作在应用层。客户端生成非对称密钥对,为每次请求单独签名生成 DPoP 证明 JWT。证明头部声明了typ: dpop+jwt、签名算法,以及以 JWK 格式呈现的客户端公钥;载荷则将证明与 HTTP 请求方法(htm)、目标 URI(htu)、唯一标识(jti)、时间戳(iat)进行绑定。授权服务器校验证明有效后,下发的访问令牌会携带 cnf claim,内含客户端公钥的 JWK 指纹,格式为"cnf": {"jkt": ""},把令牌与申请令牌的密钥绑定在一起。

客户端向资源服务器出示绑定令牌时,需要同步携带全新的 DPoP 证明。证明中包含ath claim(访问令牌的哈希值),将当前证明与特定令牌一一绑定。资源服务器校验证明签名、核对htmhtu与当前请求一致、校验令牌jkt与证明公钥匹配,全部通过才放行请求。即便令牌被盗,没有对应私钥生成合法的 DPoP 证明,令牌也毫无用处。

DPoP存储悖论:为什么基于浏览器的持有证明仍然是一个未解决的问题

图 1:DPoP 交互流程。每次请求都需要用客户端私钥签发全新证明,将令牌与申请它的密钥绑定。

目前,行业生态的落地正在加速。Spring Security 6.5于 2025 年 5 月支持 DPoP;Keycloak 26.4在 2025 年 9 月提供了该功能;Auth0于2026年3月在企业版正式推出DPoP。 OpenID 基金会 2025 年 2 月定稿的FAPI 2.0安全规范,要求金融级 API 必须采用发送方约束令牌,这也让 DPoP 成为无 mTLS 基础设施场景下,FAPI 2.0 落地的首选方案。DPoP 已经不再是理论上的方案,各团队正大规模落地。但 RFC 9449 刻意留下了一个关键的问题,那就是,在浏览器中,签名私钥究竟该存放在哪?如何避免被页面内脚本滥用? 下一节将拆解这一核心矛盾。

浏览器的固有局限:规范止步之处,正是安全隐患的开端

RFC 9449 仅要求客户端“生成”密钥对,但完全未规定它的存储位置。基于服务器的客户端不存在该问题,密钥存放在攻击者无法触达的服务器进程内存中。但对于浏览器 SPA 应用,至今没有完美的解决方案。

localStorage 和 sessionStorage 无法直接存储 CryptoKey 对象,只能将密钥序列化为可导出的 JWK 明文 JSON 字符串,而同域下的任意脚本均可读取存储内容,XSS 一行代码就能窃取原始密钥。

IETF 浏览器 OAuth 客户端最佳实践草案draft-ietf-oauth-browser-based-apps-26(6.3.4.2.2 节)推荐的方案是使用 IndexedDB 存放不可导出 CryptoKey。借助 Web Crypto API 可生成密钥对,其中私钥的extractable属性设为false,再通过结构化克隆算法存入 IndexedDB。对此密钥调用exportKey()会直接抛出InvalidAccessError,看似安全无虞。

Web Workers 虽然能将刷新令牌与主线程window作用域隔离(draft-ietf-oauth-browser-based-apps-26的6.3.4.2.1 节),却无法为签名密钥构建信任边界。同域任意脚本都可通过 postMessage 向 Worker 发起签名请求,访问令牌与签名密钥依然能被页面内任意代码访问。

最容易出现问题的关键点在于,extractable属性仅管控两个操作,即exportKey()wrapKey()。仅此而已,Web Crypto规范完全不限制sign()签名接口。不可导出的密钥指的是无法导出该密钥的字节,并不代表这个密钥不可使用。

这形成了一个认知陷阱。exportKey()的拒绝访问让开发者合理(但错误地)认为无法导出的密钥也就是无法被滥用的密钥。API 完全按照规范运行,差距在于开发者的心理模型,而非浏览器的行为。这就是存储悖论:浏览器没有提供任何机制让一个密钥既能用于签名又能防止脚本级滥用。

两行代码就能清晰看出差异:

await crypto.subtle.exportKey('jwk', privateKey);  // throws InvalidAccessErrorawait crypto.subtle.sign(alg, privateKey, payload); // succeeds — valid signature
复制代码

第一个调用会抛出异常,第二个调用却能成功生成合法的签名。

预言机攻击(Oracle Attack)

攻击者在 SPA 同域执行恶意脚本后,并不需要提取密钥字节来完成攻击。只要能访问到持有密钥的环境,就能滥用它。攻击流程如下:

  1. 注入脚本开启 IndexedDB 事务,读取已存储的 CryptoKey 句柄;

  2. 脚本使用该密钥句柄、攻击者自定义的请求头与载荷调用crypto.subtle.sign()

  3. 生成正确签名的合法 DPoP 证明 JWT,攻击者可以任意指定 HTTP 方法和目标 URI;

  4. 攻击者将该证明与被盗的访问令牌拼接,直接调用资源服务器,或通过受害者浏览器代理发起请求。

浏览器加密子系统沦为签名预言机,攻击者全程触碰不到原始密钥字节,却能肆意使用它。

DPoP存储悖论:为什么基于浏览器的持有证明仍然是一个未解决的问题

图 2:预言机攻击流程。XSS 无需提取私钥,直接利用浏览器加密能力充当签名预言机。

DPoP 的 nonce 机制(RFC 9449,第 8 节)允许授权服务器控制证明的新鲜度:服务器发放 nonce,客户端必须在下一次证明中包含它,从而防止已拦截证明的重放。然而,nonce 并不能改变预言机攻击的本质:攻击者可以在拿到当前 nonce 时按需生成每个证明,实时产生合法且不可重放的证明。

这并非空泛的理论问题。IETF 草案(Draft-ietf-oauth-browser-based-apps-26,第5.2.2节)指出,对于 DPoP,“攻击者只需通过在用户的浏览器内计算证明,就能进行在线攻击以滥用被窃的应用令牌”,并将读者引至RFC 9449的第11.4节(“客户端上下文中的不受信任代码”)以了解更多细节。第 11.4 节明确表示:如果能够在客户端的来源中执行代码,那么“对手……只要客户端在线就能创建新的 DPoP 证明”。这与密钥是否可导出无关。RFC 建议的缓解措施是预防 XSS 的安全编码实践,以及作为第二层防御的内容安全策略(CSP)。二者都在协议本身之外。因此,在同一份推荐使用 IndexedDB 存储的文档中,也在其安全考量因素中记录了协议自身无法关闭的攻击。

浏览器的不一致进一步放大了这个问题。Firefox 根本无法在 IndexedDB 中存储 CryptoKey 对象;结构化克隆算法会失败并抛出DOMException(Bugzilla #1348279,RESOLVED WONTFIX)。唯一的变通办法是将密钥导出为 JWK 格式,但这需要配置extractable: true,从而完全破坏了不可导出的安全属性。Firefox 不仅未能缓解预言机攻击,它实际上强制了一个更差的安全姿态。此外,一些浏览器在隐私浏览模式下会静默丢弃 IndexedDB 数据,为密钥持久性再增加了一个失败路径。

这些限制使基于浏览器的 DPoP 实现陷入两难境地:在 Chromium 系列浏览器中,面对预言机攻击,推荐的存储方法非常脆弱;在 Firefox 中则完全无法生效。这就是为什么工业界在很大程度上趋向于将问题完全移出浏览器。

BFF 模式:当前的行业标准

在 IETF 针对基于浏览器应用的指导(draft-ietf-oauth-browser-based-apps-26)中,按照“安全性递减顺序”介绍了其架构模式。Backend-for-Frontend(BFF)模式位列首位,也就是该草案中对基于浏览器 OAuth 的首选架构。

该模式重构了信任边界。SPA 不再作为公共 OAuth 客户端,而是在服务器端增加一个组件(BFF),作为机密客户端运行。BFF 在自身的进程内存中生成 DPoP 密钥对,使用客户端凭证处理授权码交换,管理访问与刷新令牌,并为每次向外发送的 API 请求生成 DPoP 证明。浏览器永远不会看到密钥材料或令牌;它只持有映射到 BFF 会话的 HTTP-only、Secure、SameSite Cookie 信息。

DPoP存储悖论:为什么基于浏览器的持有证明仍然是一个未解决的问题

图 3:信任边界转移。使用 BFF,密钥材料和令牌从未到达浏览器。

这种安全改善是颠覆性的,而非渐进式的。即便 SPA 被 XSS 攻破,攻击者可能通过受害者的活跃浏览器会话代理请求,但攻击面会显著缩小:攻击者无法提取令牌、无法伪造 DPoP 证明,也无法调用crypto.subtle.sign(),因为浏览器中没有用于签名的 CryptoKey。当会话 Cookie 失效或用户关闭浏览器时,攻击者的访问会随之结束。

BFF 还能阻断客户端 DPoP 根本无法应对的一类更复杂攻击,也就是,IETF 草案(Draft-ietf-oauth-browser-based-apps-26,第5.1.3节)描述的新鲜令牌获取攻击:XSS 在隐藏的 iframe 内发起静默的授权码流程,生成攻击者自己的 DPoP 密钥对,并获取完全绑定到该密钥的新令牌。攻击者不需要你的密钥,他们会自己生成新的密钥与令牌。由于 BFF 是机密客户端,授权码交换需要攻击者所不具备的客户端凭证。这是支持 BFF 模型的最强论据:它关闭了任何客户端侧加密强化也无法解决的攻击模式。

现实世界的采用情况也反映了这一共识。Duende为.NET 提供了生产级的 BFF 框架,并在代理层集成了 DPoP 证明生成;Spring Cloud Gateway通过 OAuth2 令牌中继过滤器实现了该模式;Auth.js(前身 NextAuth.js)为 Node.js 封装了该方案。在授权服务器端,Keycloak 26.4支持选择性仅对刷新令牌启用 DPoP 绑定,将刷新令牌设为发送方约束而访问令牌仍为 bearer 形式,从而在保护最敏感凭证的同时减少每次请求的开销。这与 IETF 草案draft-rosomakho-oauth-dpop-rt-00(为访问令牌与刷新令牌分离 DPoP 绑定)一致(读者应查看 IETF datatracker 以跟进草案的后续文档,该草案于 2026 年 4 月到期)。

权衡

这些权衡是真实存在且需要明确说明的。BFF 是一个额外的基础设施组件,需要部署、扩展和监控。它在 SPA 与资源服务器间增加了一个网络跳转(hop),对延迟敏感的应用会有真实可测的影响。会话管理也重新引入了基于 Cookie 的 CSRF 问题,许多团队以为在采用基于令牌的认证时已经摆脱了这些问题。共享或粘性会话会增加运维的复杂度。

对于某些部署模型,BFF 不仅昂贵,而且可能根本不可行。浏览器扩展根本没有后端服务器;嵌入在他人页面内的第三方小部件无法控制宿主的后端;基于 CDN 的离线优先 PWA 可能面临合同上禁止服务器端计算的约束。边缘函数总体上降低了部署 BFF 的门槛,但这些特定情形仍然是真正的无服务器场景。

案例研究:针对约束部署的“零持久化”方法

在本节中,我们将探索一种替代方法,用于在 BFF 不可用的场景中缓解与 DPoP 相关的风险。

在我团队执行的一个真实项目中,需要为没有后端的浏览器扩展提供无头认证层(headless authentication layer)。我们最初尝试了 IndexedDB 方案,但在跨浏览器测试的第一周内,Firefox 的 CryptoKey 序列化失败使该方案变得不可行。

这一失败促使我们探索另一种约束:如果密钥对从未触及持久化存储会怎样?

生命周期仅限于内存中。DPoP 密钥对通过crypto.subtle.generateKey()生成,设置extractable: false并保存在模块作用域的 JavaScript 变量中。它从不写入 IndexedDB、localStorage 或任何其他持久存储。页面刷新、标签关闭或用户离开页面时,密钥即被销毁。

// toBase64Url: standard Base64URL encoding per RFC 4648 §5 (omitted for brevity)const keyPair = await crypto.subtle.generateKey(  { name: 'ECDSA', namedCurve: 'P-256' },  false,  // private key non-extractable  ['sign', 'verify']);async function createDpopProof(method, url) {  const header = { alg: 'ES256', typ: 'dpop+jwt',                    jwk: await crypto.subtle.exportKey('jwk', keyPair.publicKey) };  const payload = { htm: method, htu: url, iat: Math.floor(Date.now() / 1000),                    jti: crypto.randomUUID() };  const signingInput = toBase64Url(JSON.stringify(header))                     + '.' + toBase64Url(JSON.stringify(payload));  const sig = await crypto.subtle.sign(    { name: 'ECDSA', hash: 'SHA-256' }, keyPair.privateKey,    new TextEncoder().encode(signingInput));  return signingInput + '.' + toBase64Url(sig);}
复制代码

在此模型中,如果攻击者实现了 XSS,他们仍能在当前会话期间访问内存中的密钥句柄并调用sign(),也就是预言机攻击仍然难以避免。但爆炸半径发生了变化:密钥无法在页面刷新后存活,攻击者无法跨会话建立持久性的立足点。配合严格的内容安全策略(禁止unsafe-inlineunsafe-eval),攻击窗口将缩小到注入脚本执行上下文的生命周期内。

延迟重新绑定握手。如果密钥在每次页面刷新时都销毁,将迫使用户在每次刷新时重新认证,这对任何保持打开状态的应用都是不可接受的。延迟重新绑定是确保内存模型可行的配套机制:在页面刷新时,客户端生成新的密钥对,并在与刷新令牌一起提交的新 DPoP 证明中使用新密钥签名。授权服务器验证刷新令牌,发放绑定到新公钥的新访问令牌,并使旧绑定失效。密钥的轮换成为正常浏览器行为的常规结果,而不是安全事件。这要求授权服务器支持在令牌刷新时进行 DPoP 密钥轮换。目前这并没有得到普遍的支持,但 draft-rosomakho-oauth-dpop-rt-00(撰写时仍在 IETF 积极讨论)概述了允许此类密钥轮换模式的机制。

DPoP存储悖论:为什么基于浏览器的持有证明仍然是一个未解决的问题

图 4:延迟重新绑定握手。在页面重新加载销毁旧密钥后,客户端使用新的密钥对重新建立信任。

爆炸半径缩小了,但攻击面并未彻底关闭。新鲜令牌获取攻击(IETF 草案第 5.1.3 节)对零持久化模型依然适用,就像所有公共客户端架构一样。XSS 攻击者可以发起静默的授权码流程,生成自己的密钥对,并获取新的令牌,完全绕过应用的密钥。只有机密客户端(BFF)能防止这种情况。零持久化方法缩小了预言机攻击的窗口,但并不能完全关闭公共客户端的攻击面。

多标签间的协调是正确性方面的强制要求,而不是优化。如果标签 A 刷新并生成新密钥,服务器轮转 DPoP 绑定;标签 B 仍持有旧密钥,标签 B 的证明将失效。如果没有类似 BroadcastChannel 或 SharedWorker 这样的跨标签协调机制来传播新密钥,应用将在常见用户场景中会被迫中断。

严格的 CSP 是硬性前提。授权服务器必须支持证明密钥轮换。此外,当 BFF 可行时,零持久化并不能替代 BFF;它只是针对 BFF 不可行时的特定部署约束提供的一种替代方案。

选择正确的模式

没有万能的解决方案。决策取决于你的架构能力与优先考虑的威胁模型。

  • BFF 模式。适用于已有后端基础设施、对服务器端令牌管理有合规性要求或对浏览器端风险容忍度低的团队。可以防止预言机攻击和新鲜令牌获取攻击。权衡:需要承担基础设施与延迟开销。

  • 仅内存/零持久化。适用于浏览器扩展、嵌入式小部件、仅 CDN 静态站点,或具有成熟 CSP 并能容忍短期会话的团队,它限制了持久化的预言机攻击暴露。权衡:同一个会话的 XSS 仍然难以避免;需要多标签协调;无法防止新鲜令牌获取攻击。

  • 带不可导出密钥的 IndexedDB。当与深度防御措施结合使用时是可接受的,严格的 CSP、子资源完整性(SRI)和Trusted Types(在 Chromium 83+和 Safari 26+中可用,但在 Firefox 仍然需要启用一个特定的标记)。团队必须接受预言机攻击作为残余风险。

  • 混合模式。一些团队将轻量级 BFF 用于令牌管理,同时在客户端使用 DPoP 实现 API 级的发送方约束。随着生态系统成熟,这种做法值得关注。

未来的发展方向

DPoP 确实填补了 OAuth 2.0 中的真实空白。对于任何能够实现发送方约束的客户端而言,发送方约束令牌都比 bearer 令牌具有实质性的安全提升。但 RFC 9449 对浏览器密钥存储的沉默,迫使每个团队必须审慎选择适合自身场景的架构,不存在一种适用于所有情况的安全默认方案。

对于基于浏览器的应用,BFF 模式目前是最安全的选择。纯内存方案适合强受限的场景,但依赖成熟的运维规范与威胁建模。

生态系统在持续演进。Firefox 在 IndexedDB 中存储 CryptoKey 的差距仍然令人沮丧。授权服务器对 DPoP 密钥轮换的支持正在扩大。Trusted Types 提供了一个深度防御层,在源头降低了 XSS 风险,不过,Firefox 的延迟采用反映出跨浏览器的不一致性,使 CryptoKey 的存储更加复杂化。W3C 的Web Cryptography Level 2(FPWD,2025 年 4 月)扩展了 API,但并未引入专门的密钥隔离机制。设备绑定会话凭证(Device Bound Session Credentials,DBSC)在 Chrome 的 Windows 试验中展示了 TPM 支持的密钥隔离,长期来看是最有潜力的原生方案,如果 DBSC 成熟并获得广泛浏览器支持,存储悖论也许能够得到彻底解决。

但在此之前,这个悖论依旧存在。DPoP 为我们提供了持有证明,但浏览器并没有为此提供一个安全的存放密钥的位置。

原文链接:

The DPoP Storage Paradox: Why Browser-Based Proof-of-Possession Remains an Unsolved Problem