一句話:DPoP 讓 API 通行證只有原主人用得了。每次拿通行證打 API,還得用一把只有你手上有的鑰匙,當場替「這一次」的請求蓋章。別人就算偷到通行證,沒有鑰匙也蓋不出章,通行證在他手上就沒用。
比喻:一般的通行證像演唱會門票,誰拿著都能進場。DPoP 像記名機票,登機時要出示護照,而且護照要跟機票上的名字對得上。
點圖可放大。proof=用只有你有的私鑰,對這一次請求當場簽出來的一小段資料(下一節定義)。XSS=攻擊者把自己的 JavaScript 塞進你的網頁裡執行。
DPoP 讓「偷到 token」不等於「能用 token」;但壞程式已經在用戶端裡面時,它擋不住(RFC 9449 §11.4)。
Authorization: Bearer <token>。header.payload.signature 三段,各段用 base64url 編碼,中間用點隔開。payload 裡的每個欄位叫一個 claim。{"kty":"EC","crv":"P-256","x":"…","y":"…"}。DPoP: 裡送出。每個請求都要附一張新的。記住一句就好:簽章證明「是私鑰主人做的」,指紋拿來比對「是不是同一把鑰匙」。
Bearer token 把「有權限」等同於「手上有這串字」。token 一旦外洩,例如被寫進 log、被惡意代理攔下、被同機器上的其他程式讀走,別人就能原樣拿去用,API 也分辨不出來。
DPoP 把 token 綁在用戶端的金鑰上:每個請求都要附一張 proof。攻擊者只偷到 token、沒有私鑰,就做不出 proof。
Bearer 只問「你有沒有 token」;DPoP 還要問「你有沒有那把私鑰」(RFC 9700 §2.2.1 建議這類綁定發送者的 token)。
點圖可放大。/token=向 AS 換 token 的 API;之前的登入/授權步驟不在本篇範圍。每個請求都要一張全新的 proof(RFC 9449 §4)。
拿 token 時簽一張 proof,讓 AS 記住你的公鑰;之後每打一次 API 再簽一張,證明私鑰還在你手上。
下面是 RFC 9449 範例 proof(§4.2、§7.1)解碼後的樣子:
// header
{
"typ": "dpop+jwt", // 宣告「我是 DPoP proof」,不是一般 JWT
"alg": "ES256", // 必須是公私鑰那一類演算法;不能用 none(根本不簽)
// 或 HMAC(雙方共用同一個密碼來簽,就證明不了「只有你有」)
"jwk": { // 用戶端的公鑰(絕不能含私鑰)
"kty": "EC", "crv": "P-256",
"x": "l8tFrhx-34tV3hRICRDY9zCkDlpBhF42UQUfWVAWBFs",
"y": "9VE4jf_Ok_o64zbTTlcuNJajHmt6v9TDVrU0CdvGRDA"
}
}
// payload(每個欄位綁什麼,看下圖)
{
"jti": "e1j3V_bKic8-LAEB",
"htm": "GET",
"htu": "https://resource.example.org/protectedresource",
"iat": 1562262618,
"ath": "fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo"
}
// signature:用私鑰簽 header.payload
點圖可放大。proof 用三條線把「金鑰、token、這次請求」綁在一起;htu 比對時忽略 query 和 fragment。
只取這種金鑰的必要欄位(EC 金鑰是 crv、kty、x、y),按字母順序排好、不留空白,得到一段 JSON。再對它做 SHA-256,最後轉成 base64url。上面範例公鑰算出來是 0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I,跟 RFC 9449 §6.1 範例 token 的 jkt 一致(我自己跑過驗證)。cnf 是 RFC 7800 定義的 claim,意思是「出示這個 token 的人,應該握有這把鑰匙」。
送出時,標頭跟 Bearer 只差兩處:方案名改成 DPoP,另外多帶一個 DPoP 標頭放 proof。
# Bearer(RFC 6750)
GET /protectedresource HTTP/1.1
Host: resource.example.org
Authorization: Bearer Kz~8mXK1EalYznwH-LC-1fBAo.4Ljp~zsPE_NeO.gxU
# DPoP(RFC 9449)
GET /protectedresource HTTP/1.1
Host: resource.example.org
Authorization: DPoP Kz~8mXK1EalYznwH-LC-1fBAo.4Ljp~zsPE_NeO.gxU
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7…
RS 必須拒絕用 Bearer 方案送來的 DPoP 綁定 token(RFC 9449 §7.2),不然攻擊者把方案名改回 Bearer、不附 proof,就能繞過整套機制。
proof 綁住三樣東西:哪把鑰匙、哪顆 token、哪一次請求。三樣都對得上,RS 才放行。
上圖的三條線,RS 都要逐一比對。除此之外,RFC 9449 §4.2–4.3 還要求幾項格式檢查:
DPoP 標頭。typ 必須是 dpop+jwt。alg 必須是公私鑰那一類演算法,而且用 jwk 驗得過。jwk 裡不能含私鑰。jti、htm、htu、iat;打 RS 時還要加上 ath。驗不過時回 401,原因寫在 WWW-Authenticate 標頭裡,分兩種:
WWW-Authenticate: DPoP error="invalid_dpop_proof"WWW-Authenticate: DPoP error="invalid_token"(RFC 範例的說明文字是「Invalid DPoP key binding」)另外兩件事一句帶過:
iat 的時間窗(伺服器只收剛簽好不久的 proof,RFC 建議以秒到分鐘計)擋掉,再加上同一個 jti 只收一次(§11.1)。伺服器還能要求 proof 帶上它給的值(nonce),細節另篇再談。RS 先確認 proof 格式正確,再比對三條綁定線;格式錯回invalid_dpop_proof,鑰匙對不上回invalid_token。
只用 Node 內建的 node:crypto,不用裝任何套件。存成 dpop.mjs 後執行 node dpop.mjs:
import { generateKeyPairSync, sign, verify, createHash, randomUUID, createPublicKey } from "node:crypto";
const b64u = (buf) => Buffer.from(buf).toString("base64url");
const sha256 = (s) => createHash("sha256").update(s).digest();
// 1) 用戶端產一把 P-256 金鑰(私鑰留在本機,公鑰放進 proof)
const { privateKey, publicKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
const { kty, crv, x, y } = publicKey.export({ format: "jwk" });
const jwk = { kty, crv, x, y };
// 2) 公鑰指紋 jkt(RFC 7638:必要欄位、字母順序、無空白 → SHA-256 → base64url)
const jkt = b64u(sha256(JSON.stringify({ crv, kty, x, y })));
// 3) 打 API 用的 proof
function makeProof(htm, htu, accessToken) {
const header = { typ: "dpop+jwt", alg: "ES256", jwk };
const payload = { jti: randomUUID(), htm, htu, iat: Math.floor(Date.now() / 1000) };
if (accessToken) payload.ath = b64u(sha256(accessToken));
const input = `${b64u(JSON.stringify(header))}.${b64u(JSON.stringify(payload))}`;
const sig = sign("sha256", Buffer.from(input), { key: privateKey, dsaEncoding: "ieee-p1363" });
return `${input}.${b64u(sig)}`;
}
const url = "https://resource.example.org/protectedresource";
const token = "Kz~8mXK1EalYznwH-LC-1fBAo.4Ljp~zsPE_NeO.gxU"; // RFC 9449 範例 token
const proof = makeProof("GET", url, token);
// 4) 模擬 RS 驗證(節錄關鍵幾步)
const [h, p, s] = proof.split(".");
const H = JSON.parse(Buffer.from(h, "base64url")), P = JSON.parse(Buffer.from(p, "base64url"));
const key = createPublicKey({ key: H.jwk, format: "jwk" });
console.log({
typ: H.typ === "dpop+jwt",
signature: verify("sha256", Buffer.from(`${h}.${p}`), { key, dsaEncoding: "ieee-p1363" }, Buffer.from(s, "base64url")),
htm: P.htm === "GET",
htu: P.htu === url,
iat: Math.abs(Date.now() / 1000 - P.iat) < 60,
ath: P.ath === b64u(sha256(token)), // 會得到 fUHyO2r2…(跟 RFC 範例一樣)
jkt: b64u(sha256(JSON.stringify({ crv: H.jwk.crv, kty: H.jwk.kty, x: H.jwk.x, y: H.jwk.y }))) === jkt,
});
實際執行結果:七項全部是 true。你可以試著改一下 htu 或 token,再看看哪一項變成 false。ES256 簽章要指定 dsaEncoding: "ieee-p1363",否則 JWT 驗不過。
整套機制就是三步:產金鑰、每個請求簽一張 proof、RS 逐項比對。正式環境請用成熟的函式庫。refresh token 也能綁在同一把鑰匙上,另篇再談。