简短回答:DPoP(Demonstrating Proof of Possession,演示持有证明;RFC 9449,2023年9月)把访问令牌绑定到客户端生成的密钥对上。授权服务器把公钥的指纹记录在令牌中(cnf.jkt),并在响应中返回 token_type: DPoP。每次请求时,客户端都以 DPoP 方案发送令牌,并附带一个 DPoP 请求头:这是一个用私钥签名的简短 JWT,写明 HTTP 方法(htm)、URL(htu)、唯一的 jti、时间(iat)以及访问令牌的哈希(ath)。资源服务器验证证明,拒绝重复使用的 jti 值和过旧的证明,并检查证明所用的密钥正是令牌所绑定的密钥。如果访问令牌或刷新令牌存放在可能被复制走的地方(例如浏览器和移动应用),或者 FAPI 2.0 这类安全规范要求使用发送方约束令牌,就应该使用 DPoP。
DPoP 解决什么问题
OAuth 的 Bearer 令牌(RFC 6750)就像现金:谁出示它,谁就能获得访问权限。令牌会通过日志、浏览器存储、代理、崩溃报告和被攻破的依赖项泄露出去,而泄露的 Bearer 令牌在过期之前从任何地方都能使用。发送方约束令牌增加了第二个要素,而这个要素不会随令牌一起传递。OAuth 有两种标准做法:双向 TLS(RFC 8705),把令牌绑定到客户端证书;以及 DPoP,把令牌绑定到在应用层使用的密钥。DPoP 不需要 TLS 客户端证书,可以在浏览器中使用,也能穿过终结 TLS 的代理,代价是每个请求一次签名。
DPoP 做不到的事:它不对请求体或请求头签名,只对方法和 URL 签名(§4.2);如果攻击者能在客户端内部运行代码,它也无能为力,因为这些代码可以让客户端的密钥为证明签名(§11.4)。如果要对整个请求签名,参见 HTTP 消息签名。
流程
- 客户端生成一个密钥对(最好不可导出),并在其令牌的整个生命周期内一直保留它。
- 令牌请求:客户端照常发送令牌请求,并附带一个
DPoP请求头,其中包含针对POST和令牌端点 URL 的证明。如果授权服务器需要 nonce,它会返回400和use_dpop_nonce,并附带DPoP-Nonce响应头,客户端随后在证明中带上该 nonce 重试。 - 令牌响应:
token_type为DPoP,而不是Bearer。JWT 访问令牌带有"cnf": {"jkt": "<SHA-256 JWK thumbprint>"}(§6.1,RFC 7638);不透明令牌则通过内省提供同一个值(§6.2)。签发给公共客户端的刷新令牌也会绑定到该密钥(§5)。 - 资源请求:
Authorization: DPoP <access token>,外加一个针对本次方法和 URL 的新证明,此时证明中还包含ath,即访问令牌的 base64url 编码 SHA-256 哈希。 - 资源服务器检查证明和绑定关系,它自己也可以用
401和WWW-Authenticate: DPoP error="use_dpop_nonce"要求客户端提供 nonce(§9)。
证明包含以下成员(§4.2):
| 位置 | 成员 | 含义 |
|---|---|---|
| 头部 | typ | dpop+jwt,确保证明永远不会与其他 JWT 混淆 |
| 头部 | alg | 只能是非对称算法;绝不能是 none 或 HS* |
| 头部 | jwk | 客户端的公钥;绝不能是私钥 |
| 载荷 | jti | 每个证明唯一:至少 96 位随机值,或 UUID v4 |
| 载荷 | htm、htu | HTTP 方法,以及不含查询部分和片段的目标 URI |
| 载荷 | iat | 创建时间 |
| 载荷 | ath | 访问令牌的哈希,出示访问令牌时必须提供 |
| 载荷 | nonce | 服务器最近一次下发的 DPoP-Nonce(如果服务器下发过) |
客户端:生成证明
import { randomUUID, createHash } from "node:crypto";
import { SignJWT, exportJWK, generateKeyPair } from "jose";
// One key pair per client instance. The private key is a non-extractable CryptoKey:
// code that can call sign() can make proofs, but cannot copy the key elsewhere.
const { privateKey, publicKey } = await generateKeyPair("ES256");
const jwk = await exportJWK(publicKey);
export const ath = (accessToken) => createHash("sha256").update(accessToken, "ascii").digest("base64url");
/** A DPoP proof for one request (RFC 9449 §4.2). */
export function dpopProof({ method, url, accessToken, nonce }) {
const target = new URL(url);
return new SignJWT({
jti: randomUUID(),
htm: method,
htu: target.origin + target.pathname, // without query and fragment
...(accessToken && { ath: ath(accessToken) }),
...(nonce && { nonce }),
})
.setProtectedHeader({ typ: "dpop+jwt", alg: "ES256", jwk })
.setIssuedAt()
.sign(privateKey);
}
/** Calls fetch with a fresh proof; retries once when the server asks for a nonce. */
export async function dpopFetch(url, { method = "GET", accessToken, headers = {}, body } = {}, state = {}) {
for (let attempt = 0; attempt < 2; attempt++) {
const proof = await dpopProof({ method, url, accessToken, nonce: state.nonce });
const res = await fetch(url, {
method,
body,
headers: { ...headers, DPoP: proof, ...(accessToken && { Authorization: `DPoP ${accessToken}` }) },
});
if (res.headers.has("dpop-nonce")) state.nonce = res.headers.get("dpop-nonce");
const challenge = res.headers.get("www-authenticate") ?? "";
const bodyText = res.status === 400 ? await res.clone().text() : "";
const askedForNonce = challenge.includes('error="use_dpop_nonce"') || bodyText.includes('"use_dpop_nonce"');
if (!askedForNonce || attempt === 1) return res;
}
}
在浏览器中,同样的做法可以借助 Web Crypto API 实现:用 extractable: false 生成的密钥可以保存在 IndexedDB 中并用于签名,但脚本无法读取其私钥部分。这能防止密钥被盗,却防不了页面上的恶意脚本让它签名。
资源服务器:检查证明
RFC 9449 §4.3 列出了 12 项检查,这些检查“可以按任意顺序执行”,§7 又补充了令牌绑定。下面的验证器针对单个请求实现了这些检查,注释中标有步骤编号:
import { createHash, randomBytes } from "node:crypto";
import { EmbeddedJWK, calculateJwkThumbprint, createRemoteJWKSet, jwtVerify } from "jose";
const PROOF_ALGS = ["ES256", "EdDSA", "PS256"]; // asymmetric only; never none or HS*
const MAX_PROOF_AGE = 60; // seconds
const SKEW = 5; // seconds a client clock may run ahead
export class DpopError extends Error {
constructor(error, description, headers = {}) {
super(description);
this.error = error; // invalid_token | invalid_dpop_proof | use_dpop_nonce
this.headers = headers;
}
}
const sha256 = (text) => createHash("sha256").update(text, "ascii").digest("base64url");
const normalizeHtu = (htu) => {
const u = new URL(htu); // lowercases scheme and host, drops default ports (RFC 3986 §6.2.2-6.2.3)
return u.origin + u.pathname;
};
/** Resource-server side of RFC 9449: checks §4.3 and §7 for one request. */
export function createDpopVerifier({ issuer, jwksUri, audience, publicOrigin, requireNonce }) {
const jwks = createRemoteJWKSet(new URL(jwksUri));
const seen = new Map(); // "htu jti" -> expiry, for replay detection (§11.1)
const nonce = randomBytes(16).toString("base64url"); // rotate it on a timer in production
return async function verify(req) {
// §7.1: a DPoP-bound access token arrives with the DPoP scheme, never as Bearer (§7.2).
const [scheme, accessToken, ...extra] = (req.headers.authorization ?? "").split(" ");
if (scheme !== "DPoP" || !accessToken || extra.length > 0) {
throw new DpopError("invalid_token", "DPoP authorization scheme required");
}
// Steps 1-2: exactly one DPoP header holding one JWT.
const proofs = req.headersDistinct.dpop ?? [];
if (proofs.length !== 1) throw new DpopError("invalid_dpop_proof", `${proofs.length} DPoP headers`);
// Steps 3-7 and 11: claims present, typ, asymmetric alg, signature with the embedded
// public key (EmbeddedJWK refuses private keys), and iat within the window.
let proof;
try {
proof = await jwtVerify(proofs[0], EmbeddedJWK, {
typ: "dpop+jwt",
algorithms: PROOF_ALGS,
requiredClaims: ["jti", "htm", "htu", "iat", "ath"],
maxTokenAge: MAX_PROOF_AGE,
clockTolerance: SKEW,
});
} catch (e) {
throw new DpopError("invalid_dpop_proof", e.message);
}
const { payload: p, protectedHeader } = proof;
// Steps 8-9: the proof was made for this method and this URI.
const target = new URL(req.url, publicOrigin);
if (p.htm !== req.method) throw new DpopError("invalid_dpop_proof", `htm ${p.htm} != ${req.method}`);
if (normalizeHtu(p.htu) !== target.origin + target.pathname) {
throw new DpopError("invalid_dpop_proof", `htu ${p.htu} != ${target.origin}${target.pathname}`);
}
// Step 10: the server's current nonce, if this server hands them out.
if (requireNonce && p.nonce !== nonce) {
throw new DpopError("use_dpop_nonce", "Resource server requires nonce in DPoP proof", { "DPoP-Nonce": nonce });
}
// Replay: each jti once per target URI while the proof could still be accepted.
const key = `${p.htu} ${p.jti}`;
const now = Date.now();
for (const [k, expiry] of seen) if (expiry < now) seen.delete(k);
if (seen.has(key)) throw new DpopError("invalid_dpop_proof", "DPoP proof already used");
seen.set(key, (p.iat + MAX_PROOF_AGE + SKEW) * 1000);
// The access token itself: issuer, audience, signature, expiry.
let token;
try {
({ payload: token } = await jwtVerify(accessToken, jwks, { issuer, audience, algorithms: ["RS256"] }));
} catch (e) {
throw new DpopError("invalid_token", e.message);
}
// Step 12: the proof belongs to this token, and the token is bound to the proof's key.
if (p.ath !== sha256(accessToken)) throw new DpopError("invalid_dpop_proof", "ath does not match the access token");
if (token.cnf?.jkt !== (await calculateJwkThumbprint(protectedHeader.jwk, "sha256"))) {
throw new DpopError("invalid_token", "Invalid DPoP key binding");
}
return token;
};
}
/** Turns a DpopError into the 401 response RFC 9449 §7.1 describes. */
export function challenge(err) {
const description = err.message.replaceAll('"', "'");
return {
status: 401,
headers: {
"WWW-Authenticate": `DPoP error="${err.error}", error_description="${description}", algs="${PROOF_ALGS.join(" ")}"`,
...err.headers,
},
};
}
有三个细节比看上去更重要:
publicOrigin是配置项,而不是Host请求头。在负载均衡器后面,服务器看到的是内部 URL。根据请求头重建 URL,要么让每个请求都失败,要么就得信任一个由客户端控制的请求头。- 防重放存储按目标 URI 区分,保留时长只需覆盖证明的有效窗口。一个端点后面有多个实例时,这个存储必须共享,RFC 9449 也承认这“未必总是可行”;替代方案是短期有效的服务器 nonce。
- 拒绝以
Bearer方案发送的 DPoP 绑定令牌(§7.2)。否则,对窃取令牌的人来说,绑定就成了可有可无的东西。
用 Keycloak 签发的令牌实测
实验环境运行 Keycloak 26.8.1(quay.io/keycloak/keycloak:26.8.1,开发模式),其中配置了一个机密客户端 orders-cli,要求使用绑定 DPoP 的访问令牌(客户端属性 dpop.bound.access.tokens),还配置了一个添加 orders-api 的受众映射器。一个驱动脚本使用 dpop-client.mjs 生成的证明申请令牌,在 4000 端口运行一个基于 dpop-verify.mjs、开启了 nonce 的资源服务器,然后模拟攻击者拿到复制的令牌或复制的请求后会做的尝试:
dpop_signing_alg_values_supported: PS384 RS384 EdDSA ES384 ES256 RS256 ES512 PS256 PS512 RS512
token request, proof with htm GET: 400 {"error":"invalid_request","error_description":"DPoP HTTP method mismatch"}
token request without DPoP header: 400 {"error":"invalid_request","error_description":"DPoP proof is missing"}
token response: 200, token_type DPoP
access token aud ["orders-api","account"], cnf {"jkt":"jxtFYlJLzPaVIiNgZHu-vReD5s26NbuoroqLJ-H7A1k","kc-jkt-type":"DPoP"}
thumbprint of the client's DPoP key: jxtFYlJLzPaVIiNgZHu-vReD5s26NbuoroqLJ-H7A1k
first call, no nonce yet 401 DPoP error="use_dpop_nonce", error_description="Resource server requires nonce in DPoP proof", algs="ES256 EdDSA PS256"
retry with the server's nonce 200 {"orders":[],"client":"orders-cli"}
proof sent: {"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","x":"M5K5xM809NM5egv0kblUzobF_h2cNudX-6ENCI0MjiQ","y":"qtatemMO_oibrlk6VpoXDbHPtCiZNksoyVMcIGRx33w","crv":"P-256"}} {"jti":"993999b4-d7f8-4018-9f8e-e20f2a109a71","htm":"GET","htu":"http://127.0.0.1:4000/orders","ath":"CFA2MS0HVrzwNCH1DJZgOjreMZ0qSfyrNWXR6W-rKb8","nonce":"ItQnwia8Goi2bVqV4toJFQ","iat":1792497394}
dpopFetch, no nonce known (2 requests) 200 {"orders":[],"client":"orders-cli"}
same proof replayed 401 DPoP error="invalid_dpop_proof", error_description="DPoP proof already used", algs="ES256 EdDSA PS256"
stolen token sent as Bearer 401 DPoP error="invalid_token", error_description="DPoP authorization scheme required", algs="ES256 EdDSA PS256"
stolen token, attacker's own key 401 DPoP error="invalid_token", error_description="Invalid DPoP key binding", algs="ES256 EdDSA PS256"
GET proof used for DELETE 401 DPoP error="invalid_dpop_proof", error_description="htm GET != DELETE", algs="ES256 EdDSA PS256"
proof made for another access token 401 DPoP error="invalid_dpop_proof", error_description="ath does not match the access token", algs="ES256 EdDSA PS256"
proof created two minutes ago 401 DPoP error="invalid_dpop_proof", error_description="'iat' claim timestamp check failed (too far in the past)", algs="ES256 EdDSA PS256"
运行结果说明:
- 绑定确实生效。Keycloak 返回了
token_type DPoP,以及与客户端密钥指纹相同的cnf.jkt。它还在jkt旁边添加了一个非标准成员kc-jkt-type;资源服务器必须忽略自己不认识的成员,上面的验证器就是这样做的。 - 仅凭窃取的令牌是不够的。以
Bearer方案发送时,令牌被拒绝;附上攻击者用自己的密钥生成的有效证明发送时,未能通过绑定检查。复制的请求同样失败:同一个证明无法重放,针对GET的证明不能用于DELETE,两分钟前创建的证明则已经过旧。 ath把证明限定在一个令牌上。为同一客户端、同一密钥的另一个访问令牌生成的有效证明被拒绝了。- nonce 的代价是一次往返。第一个不带 nonce 的请求收到了
401 use_dpop_nonce;dpopFetch第一次调用需要 2 个请求,之后每次只需 1 个。
Keycloak 的令牌端点同样会强制检查证明。下面是第二个简短脚本,复用了上面的客户端模块:
// Does Keycloak's token endpoint accept the same DPoP proof twice? And a proof with a wrong htu?
import { dpopProof } from "./dpop-client.mjs";
const ISSUER = "http://127.0.0.1:8080/realms/demo";
const { token_endpoint } = await (await fetch(`${ISSUER}/.well-known/openid-configuration`)).json();
const send = (proof) =>
fetch(token_endpoint, {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
authorization: `Basic ${Buffer.from(`orders-cli:${process.env.CLI}`).toString("base64")}`,
DPoP: proof,
},
body: "grant_type=client_credentials",
});
const proof = await dpopProof({ method: "POST", url: token_endpoint });
for (const label of ["first use of a proof", "same proof again"]) {
const res = await send(proof);
const body = await res.json();
console.log(`${label.padEnd(24)} ${res.status} ${body.token_type ?? JSON.stringify(body)}`);
}
const wrongUri = await send(await dpopProof({ method: "POST", url: `${ISSUER}/protocol/openid-connect/userinfo` }));
console.log(`${"proof for another URI".padEnd(24)} ${wrongUri.status} ${await wrongUri.text()}`);
first use of a proof 200 DPoP
same proof again 400 {"error":"invalid_request","error_description":"DPoP proof has already been used"}
proof for another URI 400 {"error":"invalid_request","error_description":"DPoP HTTP URL mismatch"}
有一处偏差需要了解:RFC 9449 §5 规定,令牌端点收到无效证明时返回错误码 invalid_dpop_proof,而 Keycloak 26.8.1 对上面所有证明问题都返回 invalid_request。编写客户端时,只针对 use_dpop_nonce 做分支处理,把 DPoP 令牌请求返回的其他错误当作需要报告的失败,而不是需要匹配的错误码。
nonce:服务器为什么需要它
没有服务器 nonce 时,证明是否新鲜完全取决于 iat,而这个值由客户端自己决定。任何控制客户端的人(包括它的合法用户)都可以预先签好标注未来时间的证明,之后在一台没有该密钥的机器上使用(§11.2,“预生成”)。由服务器提供、不可预测并由服务器轮换的 nonce,让证明按服务器的时钟过期。它还消除了时钟偏差的影响:服务器可以把自己的时间编码进 nonce,而不必信任 iat(§11.1)。
§8 和 §9 中的规则:
- 授权服务器用
400和"error": "use_dpop_nonce"发出信号,资源服务器用401和WWW-Authenticate: DPoP error="use_dpop_nonce"。两者都在DPoP-Nonce中发送 nonce 值。 - 服务器一旦发送过 nonce,就不得再接受不带 nonce 的证明(§11.3)。
- 服务器可以在成功响应中下发下一个 nonce,省去额外的往返;这类响应不应允许缓存。
- 只有当服务器在
Access-Control-Expose-Headers中列出DPoP-Nonce和WWW-Authenticate时,浏览器客户端才能看到这两个响应头。否则,单页应用无法读取服务器要求它使用的 nonce。
例如,Okta 的授权服务器对第一个 DPoP 令牌请求的响应是“Authorization server requires nonce in DPoP proof”(Okta 指南),所以客户端从一开始就需要实现重试。
绑定授权码:dpop_jkt
DPoP 在令牌端点绑定令牌,但授权码更早就已签发,而且经过浏览器传递。在授权请求中加入 dpop_jkt(§10),客户端就能预先声明它将使用的密钥的指纹,授权服务器则会拒绝证明使用了其他密钥的令牌请求。与 PKCE 结合,只要客户端为每个授权请求使用新密钥,就能端到端地保护整个流程。FAPI 2.0 要求使用 DPoP 的授权服务器支持这种绑定,但客户端可以自行选择是否使用(FAPI 2.0 安全规范 §5.3.2.1 和 §5.3.3.1)。
何时使用 DPoP
| 场景 | 是否使用 DPoP? |
|---|---|
| 持有刷新令牌的单页应用或移动应用 | 是:签发给公共客户端的刷新令牌会绑定到密钥,这正是 DPoP 最能发挥作用的地方 |
| FAPI 2.0(开放银行及类似场景) | 实际上必须使用:FAPI 2.0 要求通过 mTLS 或 DPoP 使用发送方约束令牌 |
| 已经部署双向 TLS 的服务器到服务器通信 | mTLS 绑定(RFC 8705)是更简单的选择 |
| 从不向浏览器暴露令牌的机密 Web 应用 | 可选:令牌保留在服务端,其刷新令牌也已经与客户端身份验证绑定 |
你无法控制、只接受 Bearer 的资源服务器 | 作用有限:在那里令牌只能作为 Bearer 令牌使用,得不到这种保护(§7.2) |
Keycloak(上文已测试)、Okta 和 Auth0(文档)都为各自的令牌端点提供了 DPoP 文档,服务器还会在元数据中以 dpop_signing_alg_values_supported 公布可接受的证明算法(§5.1)。缺口通常在资源服务器一侧,这也正是上面这个验证器的意义所在。
常见错误
- 只检查证明,不检查绑定。由任意密钥签名的证明本身都是有效的;只有
cnf.jkt才把它和令牌联系起来。 - 以
Bearer方案发送的令牌也照样接受。这样攻击者只需改用 Bearer 方案即可。 - 拿
htu与带查询字符串的 URL 或内部 URL 比较。htu不含查询部分和片段,必须与规范化之后的公开 URL 一致。 - 既没有防重放存储,也没有 nonce。这样复制的证明在整个
iat窗口内都能再次使用。 - 每个请求都换新密钥。只要绑定到该密钥的令牌仍在使用,密钥就必须保持不变。
- 使用对称
alg,或在jwk中放入私钥。两者都被 §4.2 禁止;jose 的EmbeddedJWK会拒绝后者。
参考资料
- RFC 9449:OAuth 2.0 演示持有证明(DPoP),§4 至 §11
- RFC 7638:JSON Web Key(JWK)指纹
- RFC 8705:OAuth 2.0 双向 TLS 客户端身份验证与证书绑定的访问令牌
- RFC 6750:Bearer 令牌的使用
- FAPI 2.0 安全规范,§5.3
- Okta:配置 OAuth 2.0 DPoP 和 Auth0:DPoP
- jose:
EmbeddedJWK、calculateJwkThumbprint