本文へスキップ
ecdsa.com
← すべての記事

DPoP 解説:OAuth トークンをクライアントの鍵に紐付ける

ベアラートークンは、持っている人なら誰でも使えます。DPoP で紐付けられたトークンは、クライアントの秘密鍵による新しい署名と組み合わせたときにしか使えないため、コピーしたトークンだけでは役に立ちません。この記事では、プロトコル、クライアントとサーバーのコード、そして Keycloak が発行したトークンとプルーフを盗用・リプレイ・再利用したときにリソースサーバーが何を拒否したかを紹介します。

要点: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 が解決する問題

OAuth のベアラートークン(RFC 6750)は現金のようなもので、提示した人なら誰でもアクセスできます。トークンはログ、ブラウザーのストレージ、プロキシ、クラッシュレポート、侵害された依存関係などを通じて漏洩し、漏洩したベアラートークンは有効期限が切れるまでどこからでも使えます。送信者制約付きトークンは、トークンと一緒には移動しない 2 つ目の要素を追加します。OAuth にはこれを実現する標準的な方法が 2 つあります。トークンをクライアント証明書に紐付ける相互 TLS(RFC 8705)と、アプリケーション層で使う鍵に紐付ける DPoP です。DPoP は TLS クライアント証明書を必要とせず、ブラウザーからでも、TLS を終端するプロキシを経由しても機能し、コストはリクエストごとに署名 1 回です。

DPoP が行わないこともあります。署名するのはメソッドと URL だけで、リクエストのボディやヘッダーには署名しません(§4.2)。また、攻撃者がクライアントの内部でコードを実行できる場合は役に立ちません。そのコードはクライアントの鍵を使ってプルーフに署名できるからです(§11.4)。リクエスト全体に署名するには、HTTP メッセージ署名を参照してください。

処理の流れ

  1. クライアントは鍵ペアを生成し(できればエクスポート不可にします)、そのトークンを使う間はずっとこの鍵ペアを保持します。
  2. トークンリクエスト:クライアントは通常のトークンリクエストを送信し、その際に POST とトークンエンドポイントの URL に対するプルーフを含む DPoP ヘッダーを付けます。認可サーバーが nonce を求める場合は、use_dpop_nonce と DPoP-Nonce ヘッダーを付けて 400 で応答し、クライアントはその nonce をプルーフに入れて再試行します。
  3. トークンレスポンス:token_type は Bearer ではなく DPoP です。JWT 形式のアクセストークンには "cnf": {"jkt": "<SHA-256 JWK thumbprint>"} が含まれ(§6.1、RFC 7638)、不透明なトークンではイントロスペクションで同じ値を取得できます(§6.2)。パブリッククライアントに発行されたリフレッシュトークンも鍵に紐付けられます(§5)。
  4. リソースリクエスト:Authorization: DPoP <access token> に加えて、このメソッドと URL に対する新しいプルーフを送信します。今回のプルーフには、アクセストークンの SHA-256 ハッシュ値を base64url で表した ath も含めます。
  5. リソースサーバーはプルーフと紐付けをチェックします。リソースサーバー自身も、401 と WWW-Authenticate: DPoP error="use_dpop_nonce" で nonce を要求できます(§9)。

プルーフには次のメンバーが含まれます(§4.2)。

場所メンバー意味
ヘッダーtypdpop+jwt。プルーフがほかの JWT と混同されることを防ぐ
ヘッダーalg非対称アルゴリズムのみ。none や HS* は絶対に不可
ヘッダーjwkクライアントの公開鍵。秘密鍵は絶対に不可
ペイロードjtiプルーフごとに一意な値。96 ビット以上のランダム値、または UUID v4
ペイロードhtm、htuHTTP メソッドと、クエリとフラグメントを除いた対象 URI
ペイロードiat作成時刻
ペイロードathアクセストークンのハッシュ値。アクセストークンを提示する場合は必須
ペイロードnonceサーバーが最後に送った DPoP-Nonce(送られている場合)

クライアント:プルーフの作成

dpop-client.mjs(jose 6.2.13、Node.js 24.22)
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 ではさらにトークンの紐付けが加わります。次の検証コードは、1 つのリクエストに対してこれらのチェックを実装したもので、コメントにはステップ番号を記しています。

dpop-verify.mjs(jose 6.2.13)
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,
    },
  };
}

実装の詳細のうち、見た目以上に重要なものが 3 つあります。

  • publicOrigin は設定値であり、Host ヘッダーの値ではありません。ロードバランサーの背後では、サーバーから見えるのは内部の URL です。リクエストヘッダーから URL を組み立て直すと、すべてのリクエストが失敗するか、クライアントが自由に設定できるヘッダーを信頼することになります。
  • リプレイ検出用のストアは対象 URI ごとに分け、保持するのはプルーフの受付期間だけです。1 つのエンドポイントの背後に複数のインスタンスがある場合はストアを共有しなければなりませんが、RFC 9449 自身もそれが「常に実現できるとは限らない」と認めています。代わりの手段は、有効期間の短いサーバー nonce です。
  • Bearer として送られた DPoP 紐付きトークンは拒否してください(§7.2)。そうしないと、紐付けを使うかどうかを、トークンを盗んだ側が選べてしまいます。

Keycloak が発行したトークンでのテスト

検証環境では Keycloak 26.8.1(quay.io/keycloak/keycloak:26.8.1、開発モード)を動かし、DPoP で紐付けられたアクセストークンを必須にした(クライアント属性 dpop.bound.access.tokens)コンフィデンシャルクライアント orders-cli を 1 つと、orders-api を追加するオーディエンスマッパーを設定しました。テスト用のドライバーは、dpop-client.mjs で作成したプルーフを付けてトークンを要求し、dpop-verify.mjs をもとにしたリソースサーバーを nonce を有効にしてポート 4000 で起動したうえで、トークンやリクエストをコピーした攻撃者が試しそうな操作を実行しました。

出力:Keycloak 26.8.1 と上記のリソースサーバー(node:24.22.0-bookworm-slim)
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 を返しました。Keycloak は jkt の隣に非標準のメンバー kc-jkt-type も追加します。リソースサーバーは未知のメンバーを無視しなければならず、上の検証コードもそのように動作します。
  • 盗んだトークンだけでは使えません。Bearer として送ると拒否され、攻撃者自身の鍵による有効なプルーフを付けて送ると紐付けのチェックで失敗しました。コピーしたリクエストも失敗しました。同じプルーフはリプレイできず、GET 用のプルーフは DELETE には使えず、2 分前に作成したプルーフは古すぎると判定されました。
  • ath はプルーフを 1 つのトークンに結び付けます。同じクライアントと鍵で、別のアクセストークン用に作成した有効なプルーフは拒否されました。
  • nonce のコストは 1 往復です。nonce のない最初のリクエストには 401 use_dpop_nonce が返りました。dpopFetch は、最初の呼び出しでは 2 回のリクエストを必要とし、それ以降は 1 回で済みました。

Keycloak のトークンエンドポイントでもプルーフがチェックされます。クライアントのモジュールを再利用した、2 つ目の短いスクリプトで確認しました。

as-replay.mjs
// 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"}

知っておくべき仕様との相違が 1 つあります。RFC 9449 §5 では、トークンエンドポイントで無効なプルーフにはエラーコード invalid_dpop_proof を返すとされていますが、Keycloak 26.8.1 は上記のプルーフの問題すべてに invalid_request で応答しました。クライアントは use_dpop_nonce でのみ分岐するように実装し、DPoP のトークンリクエストで返るそれ以外のエラーは、照合するコードとしてではなく、報告すべき失敗として扱ってください。

nonce:サーバーが nonce を要求する理由

サーバー nonce がなければ、プルーフの新しさはクライアントが決める値である iat だけに依存します。クライアントを制御できる人なら、正規のユーザーも含めて誰でも、未来の日時を付けたプルーフに署名しておき、あとで鍵を持たないマシンからそれを使えます(§11.2、「事前生成」)。サーバーが提供する nonce は予測できず、サーバーがローテーションするため、プルーフはサーバーの時計に基づいて期限切れになります。さらに、クライアントとサーバーの時計のずれも問題でなくなります。サーバーは iat を信頼する代わりに、自分の時刻を nonce にエンコードできるからです(§11.1)。

§8 と §9 の規則は次のとおりです。

  • 認可サーバーは 400 と "error": "use_dpop_nonce" で、リソースサーバーは 401 と WWW-Authenticate: DPoP error="use_dpop_nonce" で nonce を要求します。どちらも値は DPoP-Nonce で送ります。
  • サーバーは、一度 nonce を送ったら、nonce を含まないプルーフを受け付けてはいけません(§11.3)。
  • サーバーは成功レスポンスで次の nonce を送ることができ、そうすれば余分な往復を省けます。そのようなレスポンスはキャッシュ可能にすべきではありません。
  • ブラウザーのクライアントが DPoP-Nonce と WWW-Authenticate を読み取れるのは、サーバーがそれらを Access-Control-Expose-Headers に列挙している場合だけです。これがないと、シングルページアプリケーションは、使うよう求められている 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 しか受け付けない、自分で管理していないリソースサーバー限定的:そのサーバーではトークンを保護のないベアラートークンとしてしか使えない(§7.2)

Keycloak(上記でテスト済み)、Okta、Auth0(ドキュメント)は、いずれもトークンエンドポイントでの DPoP をドキュメントで説明しており、サーバーは受け付けるプルーフのアルゴリズムをメタデータの dpop_signing_alg_values_supported で公開しています(§5.1)。対応が欠けているのはたいていリソースサーバー側であり、だからこそ上記の検証コードが重要になります。

よくある間違い

  1. プルーフはチェックするが、紐付けはチェックしない:どの鍵で署名したプルーフも、それ単体では有効です。プルーフをトークンに結び付けるのは cnf.jkt だけです。
  2. トークンを Bearer としても受け付ける:そうすると、攻撃者は単に Bearer スキームを使うだけです。
  3. htu をクエリ文字列付きの URL や内部 URL と比較する:htu にはクエリとフラグメントが含まれず、正規化したうえで公開 URL と一致しなければなりません。
  4. リプレイ検出用のストアも nonce もない:コピーしたプルーフが、iat の受付期間中ずっと再び使えてしまいます。
  5. リクエストごとに新しい鍵を使う:鍵は、その鍵に紐付けられたトークンを使っている間、同じでなければなりません。
  6. 対称アルゴリズムの alg、または jwk 内の秘密鍵:どちらも §4.2 で禁止されています。jose の EmbeddedJWK は後者を拒否します。

出典