Коротко. DPoP (Demonstrating Proof of Possession, доказательство владения ключом; RFC 9449, сентябрь 2023 года) привязывает токен доступа к паре ключей, которую генерирует клиент. Сервер авторизации записывает в токен отпечаток открытого ключа (cnf.jkt) и указывает в ответе token_type: DPoP. В каждом запросе клиент отправляет токен со схемой DPoP и добавляет заголовок DPoP — короткий JWT, подписанный закрытым ключом, в котором указаны метод HTTP (htm), URL (htu), уникальный jti, время (iat) и хеш токена доступа (ath). Сервер ресурсов проверяет доказательство, отклоняет повторные значения jti и устаревшие доказательства и убеждается, что ключ доказательства — тот самый, к которому привязан токен. Используйте DPoP, если токены доступа или токены обновления хранятся там, откуда их можно скопировать, например в браузерах и мобильных приложениях, или если профиль вроде FAPI 2.0 требует токенов с привязкой к отправителю.
Какую задачу решает DPoP
Bearer-токен OAuth (RFC 6750) похож на наличные: доступ получает любой, кто его предъявит. Токены утекают через логи, хранилище браузера, прокси, отчёты о сбоях и скомпрометированные зависимости, а утёкший bearer-токен работает откуда угодно, пока не истечёт срок его действия. Токены с привязкой к отправителю добавляют второй фактор, который не передаётся вместе с токеном. В OAuth для этого есть два стандартных способа: взаимная TLS-аутентификация (RFC 8705), которая привязывает токен к клиентскому сертификату, и DPoP, который привязывает его к ключу, используемому на прикладном уровне. Для DPoP не нужны клиентские сертификаты TLS, он работает из браузера и через прокси, на которых завершается TLS, и обходится в одну подпись на запрос.
Чего DPoP не делает: он не подписывает тело и заголовки запроса, только метод и URL (§ 4.2), и не помогает, если злоумышленник может выполнить код внутри клиента, потому что этот код может подписывать доказательства ключом клиента (§ 11.4). О подписи запросов целиком читайте в статье HTTP Message Signatures.
Как это работает
- Клиент генерирует пару ключей, в идеале неизвлекаемую, и хранит её, пока действуют его токены.
- Запрос токена. Клиент отправляет обычный запрос токена с заголовком
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— хешем SHA-256 токена доступа в base64url. - Сервер ресурсов проверяет доказательство и привязку и может сам потребовать nonce ответом
401сWWW-Authenticate: DPoP error="use_dpop_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 перечислены двенадцать проверок, которые «можно выполнять в любом порядке», а § 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 сервера.
- Отклоняйте токен с привязкой DPoP, отправленный как
Bearer(§ 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, «pre-generation» — заблаговременное создание доказательств). Nonce, который выдаёт сервер, непредсказуем и регулярно меняется самим сервером, поэтому доказательства устаревают по часам сервера. Кроме того, он снимает проблему расхождения часов: сервер может закодировать в nonce собственное время и не доверять iat (§ 11.1).
Правила из § 8 и § 9:
- Серверы авторизации сообщают о необходимости nonce ответом
400с"error": "use_dpop_nonce", серверы ресурсов — ответом401сWWW-Authenticate: DPoP error="use_dpop_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) — более простой выбор |
| Конфиденциальное веб-приложение, которое никогда не передаёт токены в браузер | Необязателен: токены остаются на стороне сервера, а токены обновления такого приложения уже привязаны к аутентификации клиента |
Серверы ресурсов, которыми вы не управляете и которые принимают только 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;EmbeddedJWKиз jose отклоняет второй.
Источники
- RFC 9449: доказательство владения ключом в OAuth 2.0 (DPoP), §§ 4–11
- RFC 7638: отпечаток JSON Web Key (JWK)
- RFC 8705: взаимная TLS-аутентификация клиентов в OAuth 2.0 и токены доступа, привязанные к сертификатам
- RFC 6750: использование bearer-токенов
- Профиль безопасности FAPI 2.0 (FAPI 2.0 Security Profile), § 5.3
- Okta: настройка DPoP для OAuth 2.0 и Auth0: DPoP
- jose:
EmbeddedJWK,calculateJwkThumbprint