oauth · L7

DPoP:把 token 綁在用戶端的鑰匙上

Chance Lu · 學習文章 · 2026-10-30 18:00
Chance Lu

一句話:DPoP 讓 API 通行證只有原主人用得了。每次拿通行證打 API,還得用一把只有你手上有的鑰匙,當場替「這一次」的請求蓋章。別人就算偷到通行證,沒有鑰匙也蓋不出章,通行證在他手上就沒用。

比喻:一般的通行證像演唱會門票,誰拿著都能進場。DPoP 像記名機票,登機時要出示護照,而且護照要跟機票上的名字對得上。

總覽:通行證被偷了,會發生什麼事

① Bearer:撿到就能用 用戶端合法拿到 token API只看 token → 照收 攻擊者 token 外洩(log、代理、被偷) 拿 token 打 API ② DPoP:token + 每次當場簽的 proof 用戶端私鑰只在這裡 API缺 proof → 拒絕 攻擊者 token + proof 只偷到 token 沒私鑰,做不出 proof ③ DPoP 的極限:壞程式已經在用戶端裡執行 用戶端裡面跑著壞程式(例如 XSS) APIproof 是真的 → 照收 token + 壞程式當場借用私鑰簽的 proof

點圖可放大。proof=用只有你有的私鑰,對這一次請求當場簽出來的一小段資料(下一節定義)。XSS=攻擊者把自己的 JavaScript 塞進你的網頁裡執行。

DPoP 讓「偷到 token」不等於「能用 token」;但壞程式已經在用戶端裡面時,它擋不住(RFC 9449 §11.4)。

先認識 10 個名詞

用戶端(client)
代替使用者去打 API 的程式,例如網頁前端或手機 App。
授權伺服器(AS)/資源伺服器(RS)
AS(authorization server)負責發 token;RS(resource server)就是你要打的 API,負責收 token、決定給不給資料。
Access token(存取權杖)
AS 發給用戶端的一串字,就是上面說的「API 通行證」。打 API 時拿出來,代表「我有權限」。
Bearer token(持有者權杖)
最常見的 access token 用法。RFC 6750 的定義是:任何持有它的人都能用它,不必證明自己握有任何金鑰。寫法是 Authorization: Bearer <token>。
公鑰/私鑰
成對產生的兩把鑰匙。私鑰自己藏好,公鑰可以公開給任何人。
簽章(signature)
用私鑰對一段資料算出的一串值。任何人都能拿公鑰驗證「這段資料確實是私鑰主人簽的、而且沒被改過」,但沒有私鑰就偽造不出來。
JWT(JSON Web Token,RFC 7519)
一種裝資料的格式:header.payload.signature 三段,各段用 base64url 編碼,中間用點隔開。payload 裡的每個欄位叫一個 claim。
JWK(JSON Web Key,RFC 7517)
用 JSON 表示一把金鑰,例如 {"kty":"EC","crv":"P-256","x":"…","y":"…"}。
雜湊/指紋(hash/thumbprint)
把任意資料丟進 SHA-256 之類的函式,得到固定長度的一串值。同樣的輸入一定得到同樣的值,但沒辦法倒推回原文。對一把公鑰算出來的雜湊,就叫這把鑰匙的「指紋」。
DPoP proof
用私鑰對這一次請求簽出來的一個小 JWT,放在 HTTP 標頭 DPoP: 裡送出。每個請求都要附一張新的。
記住一句就好:簽章證明「是私鑰主人做的」,指紋拿來比對「是不是同一把鑰匙」。

為什麼需要它:Bearer token 的弱點

Bearer token 把「有權限」等同於「手上有這串字」。token 一旦外洩,例如被寫進 log、被惡意代理攔下、被同機器上的其他程式讀走,別人就能原樣拿去用,API 也分辨不出來。

DPoP 把 token 綁在用戶端的金鑰上:每個請求都要附一張 proof。攻擊者只偷到 token、沒有私鑰,就做不出 proof。

Bearer 只問「你有沒有 token」;DPoP 還要問「你有沒有那把私鑰」(RFC 9700 §2.2.1 建議這類綁定發送者的 token)。

運作流程:從拿 token 到打 API

用戶端 AS RS(API) ① 產生一對金鑰(私鑰不離開裝置) ② POST /tokenDPoP 標頭:簽了「POST /token」的 proof ③ 驗 proof、算出公鑰指紋 token 是 JWT → 在裡面加 cnf.jkt 欄位 token 不是 JWT → AS 自己記下來 ④ access token(token_type: DPoP) ⑤ GET /protectedresource Authorization: DPoP <token>DPoP 標頭:另簽一張「GET 這個網址 + 這顆 token」的新 proof ⑥ 驗 proof,比對 method/URL/時間 比對 token 雜湊、比對公鑰指紋 ⑦ 200 OK(任一項不符 → 401)

點圖可放大。/token=向 AS 換 token 的 API;之前的登入/授權步驟不在本篇範圍。每個請求都要一張全新的 proof(RFC 9449 §4)。

拿 token 時簽一張 proof,讓 AS 記住你的公鑰;之後每打一次 API 再簽一張,證明私鑰還在你手上。

proof 怎麼把 token 和這次請求綁在一起

下面是 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
DPoP proof header.jwk(公鑰) payload.ath payload.htm + htu payload.jti + iat → 防同一張 proof 被再用一次 access token cnf.jkt = 公鑰指紋 AS 發 token 時寫進去 這一次 HTTP 請求 GET https://resource.example.org /protectedresource 算指紋,要等於 jkt token 雜湊要相符 method、URL 要相符

點圖可放大。proof 用三條線把「金鑰、token、這次請求」綁在一起;htu 比對時忽略 query 和 fragment。

公鑰指紋怎麼算(RFC 7638)

只取這種金鑰的必要欄位(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 怎麼驗

上圖的三條線,RS 都要逐一比對。除此之外,RFC 9449 §4.2–4.3 還要求幾項格式檢查:

驗不過時回 401,原因寫在 WWW-Authenticate 標頭裡,分兩種:

另外兩件事一句帶過:

RS 先確認 proof 格式正確,再比對三條綁定線;格式錯回 invalid_dpop_proof,鑰匙對不上回 invalid_token。

動手跑:用 Node 內建模組產 proof 並自己驗

只用 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 也能綁在同一把鑰匙上,另篇再談。

參考來源