---
title: "От чего защищает DPoP, а от чего нет — FoxAuth"
description: "Утёкший токен доступа работает у любого, кто его нашёл. DPoP привязывает токен к ключу клиента. Что это даёт, чего стоит и когда я бы не стал возиться."
canonical: "https://foxauth.dev/ru/blog/what-dpop-protects-you-from/"
lastmod: "2026-10-08T21:45:57+03:00"
---
Блог
От чего защищает DPoP, а от чего нет
Утёкший токен доступа работает у любого, кто его нашёл. DPoP привязывает токен к ключу клиента. Что это даёт, чего стоит и когда я бы не стал возиться.
Автор: FoxAuth · 2026-09-18

Токен нашёлся в отчёте о падении приложения.

Я отвечал за API, и к нему подключали мобильное приложение (в терминах OAuth — клиент). Когда в
приложении случалась ошибка, которую оно не обрабатывало, его HTTP-библиотека упаковывала упавший
запрос и отправляла во внешний сервис сбора отчётов о падениях. Вместе с заголовками. Так в панели
этого сервиса оказался заголовок Authorization: Bearer eyJhbGci.... Токен действовал ещё
четырнадцать минут. Прочитать его мог любой дежурный из поддержки этого сервиса. Насколько я знаю,
никто им не воспользовался. Но если бы кто-то воспользовался, протокол бы ему никак не помешал.

Это не ошибка в чьём-то коде. Так устроен bearer-токен («токен на предъявителя»). В RFC 6750 он так и
определён: пользоваться им может любой, у кого он есть. Сервер ресурсов — это API, который принимает
токен. Он проверяет подпись токена, срок действия, аудиторию (для какого API выпущен токен) и scope
(что токен разрешает). Но он никогда не задаёт вопрос, который был важен в тот день: тот ли это
клиент, которому выдали токен? Он и не может его задать. В запросе нет ничего, по чему это можно
проверить.

DPoP (RFC 9449) добавляет в запрос то, по чему это можно проверить. Хочу точно разобрать, что DPoP
даёт, а что нет. Я видел, как его продают под лозунгом «останавливает кражу токенов». На деле он
делает вещь поуже — и полезнее.

Что клиент подписывает в каждом запросе и почему украденный токен без ключа бесполезен

Ещё до того, как просить токен, клиент создаёт пару ключей: открытый и закрытый. Закрытый ключ он
никому не отдаёт. Дальше к каждому запросу с токеном клиент прикладывает небольшой подписанный JWT. Он
называется доказательством (proof). В заголовке доказательства лежит открытый ключ. В payload (теле
JWT) описан запрос, к которому это доказательство относится. Вот доказательство для вызова эндпоинта
userinfo в раскодированном виде:

{

"typ": "dpop+jwt",

"alg": "ES256",

"jwk": { "kty": "EC", "crv": "P-256", "x": "l8tFrhx-34tV3hRICRDY9zCkDlpBhF42UQUfWVAWBFs", "y": "9VE4jf_Ok_o64zbTTlcuNJajHmt6v9TDVrU0CdvGRDA" }

}

{

"jti": "e1j3V_bKic8-LAEB",

"htm": "GET",

"htu": "https://auth.example.com/userinfo",

"iat": 1758196200,

"ath": "fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo",

"nonce": "eyJ7S_zG8SxF0SbEjb-5aYW7Xbt9Jd_kKdIaI4BbX2o"

}

Поля JWT называются claim. Каждый claim в доказательстве закрывает одну лазейку. Без него прошёл бы
определённый повтор (replay) — это когда атакующий ещё раз отправляет то, что перехватил. Схема ниже
показывает, какой claim какую лазейку закрывает. Следите за стрелками. Каждая идёт от доказательства
к тому, что сервер может проверить сам, не полагаясь на клиента.

На выделенной цветом стрелке держится вся схема. Это и есть привязка к отправителю
(sender-constraint): токен привязан к ключу, и тот, кто отправляет токен, должен доказать, что этот
ключ у него есть. Внутри токена доступа лежит claim cnf.jkt (cnf — от confirmation,
«подтверждение»). В нём записан отпечаток (thumbprint) открытого ключа, которым подписано
доказательство. Отпечаток — это короткий хеш ключа, посчитанный по RFC 7638. Зачем он нужен? Сервер
ресурсов мог ни разу не общаться с клиентом. Но по одному токену он всё равно узнаёт, к какому ключу
привязан токен. А потом проверяет, что доказательство подписано именно этим ключом.

Остальные claim делают вот что. ath — хеш самого токена доступа. Он не даёт снять доказательство с
одного токена и прицепить к другому, привязанному к тому же ключу. htm и htu — метод и URL
запроса. Они не дают взять доказательство для одного эндпоинта и повторить его на другом. jti —
уникальный идентификатор доказательства. По нему сервер замечает, что одно и то же доказательство
пришло дважды. nonce и iat отвечают за свежесть — то есть за то, насколько недавно сделано
доказательство. Им отведён отдельный раздел: именно с ними больше всего хлопот, когда DPoP работает в
продакшене.

Вот как всё это выглядит в настоящем HTTP-трафике. Начнём с обмена кода: клиент меняет код
авторизации на токены. По сравнению с обычным обменом с PKCE нового здесь только две вещи: заголовок
DPoP в запросе и поле token_type в ответе:

POST /token HTTP/1.1

Host: auth.example.com

Content-Type: application/x-www-form-urlencoded

DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7Imt0eSI6IkVDIi...

grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA

&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcb

&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk&client_id=spa-7f3a

HTTP/1.1 200 OK

Content-Type: application/json

Cache-Control: no-store

{

"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6ImF0K2p3dCIsImtpZCI6IjIwMjYtMDkifQ...",

"token_type": "DPoP",

"expires_in": 900,

"scope": "openid orders:read",

"refresh_token": "Ql3oQ4fq8NWY0m9tHqqvqA1H7ArTn0O1x1PbUdECfxa"

}

token_type: DPoP говорит клиенту, какую схему теперь писать в заголовке Authorization. Клиент
отправляет Authorization: DPoP <token>, а не Bearer. Если сервер получит привязанный к DPoP токен
со схемой Bearer, он должен его отклонить. Почему? Раз клиент пишет Bearer, он не знает, что
токен привязан к ключу. Значит, и доказательство он не пришлёт.

Где хранится сама привязка, зависит от вида токена. Opaque-токен — это случайная строка без
содержимого. Что за ней стоит, знает только сервер авторизации. Чтобы проверить такой токен, сервер
ресурсов спрашивает о нём сервер авторизации (такой запрос называется интроспекцией). Привязка
приходит в ответе, в поле cnf. А JWT несёт своё содержимое в себе. Если токен доступа — JWT, как
здесь, то же поле cnf лежит прямо в его payload:

{

"iss": "https://auth.example.com",

"sub": "248289761001",

"aud": "https://api.example.com",

"client_id": "spa-7f3a",

"exp": 1758197100,

"scope": "openid orders:read",

"cnf": { "jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I" }

}

Итак, вся последовательность — два шага на сервере авторизации, потом блок проверок на сервере
ресурсов. Самое важное — этот блок проверок, шаг 4. Его стоит прочитать внимательно.

Теперь прогоним через эту схему тот самый отчёт о падении. У инженера поддержки есть токен доступа.
Но у него нет закрытого ключа K, поэтому подписать доказательство он не может. Доказательство,
подписанное любым другим ключом, не пройдёт шаг 4 — сверку отпечатка. Сам по себе токен ничего не
стоит.

А теперь допустим, что он перехватил весь запрос — токен вместе с доказательством. Такое
доказательство годится только для одного метода, одного URL и одного токена. Свежим оно остаётся либо
пять минут (по iat), либо пока сервер принимает его nonce. А если сервер ресурсов ведёт хранилище
повторов (помнит jti, которые уже принял), доказательство сработает ровно один раз. Был пароль,
который работает четырнадцать минут. Стал один-единственный запрос, который атакующий, может быть,
сумеет повторить, а может, и нет.

От чего DPoP не защищает: от чужого кода там, где хранится ключ

Это настоящее улучшение. Но другого DPoP не даёт. Он защищает, когда токен утёк уже после выдачи и
где-то вне клиента. Например, в отчёте о падении, в логе прокси, в заголовке Referer, через
расширение браузера, которое читает хранилище, или через неправильно настроенный кеш. Во всех этих
случаях у атакующего есть токен, но нет ключа.

Если код атакующего выполняется там же, где хранится ключ, DPoP бессилен. Возьмём XSS (межсайтовый
скриптинг) — когда чужой скрипт выполняется прямо на вашей странице. Пусть одностраничное приложение
(веб-приложение, которое целиком работает в браузере) хранит ключ в IndexedDB — базе данных внутри
браузера. Чужому скрипту не нужно ничего красть и выносить наружу. Он подписывает доказательства
настоящим ключом. Вызывает API от имени настоящего пользователя, с настоящей страницы. И каждая
проверка на шаге 4 проходит — как и должна. Привязать токен к ключу, которым может пользоваться и
атакующий, — значит ничего не защитить.

Я не раз слышал на дизайн-ревью такой обмен репликами: «Что будет, если фронтенд взломают?» — «Мы
используем DPoP». Это вообще не ответ на этот вопрос. DPoP мешает повторить украденный токен где-то в
другом месте. Но он не поможет, если взломано само место, где хранится ключ.

Скучные базовые меры DPoP тоже не заменяет. Возьмём привязанный к DPoP токен, который живёт четыре
часа и не ограничен аудиторией. Тот, кто взломает клиент, получит токен, который четыре часа работает
с любым API. Ущерб ограничивают другие вещи: короткий срок жизни токенов, ограничение аудитории и
ротация refresh-токенов. Refresh-токен живёт дольше токена доступа, и по нему клиент получает новые
токены доступа. Ротация — это когда при каждом его использовании выдаётся новый refresh-токен. DPoP
работает вместе с этими мерами, а не вместо них.

Зачем сервер выдаёт nonce: часы клиента могут врать

Без nonce о свежести доказательства говорит только iat — время, когда его подписали. Значит, всё
зависит от того, совпадают ли часы клиента с часами сервера. Наш сервер принимает iat, если оно
расходится с его временем не больше чем на 300 секунд. Размер этого окна RFC сознательно оставляет на
усмотрение сервера. Теперь возьмём телефон, у которого часы ушли на шесть минут. Каждый его запрос
получает ошибку invalid_dpop_proof. И ничто в этой ошибке не говорит «у вас неправильные часы». Она
говорит только, что доказательство плохое. Кто-то потратит на это полдня. Этим кем-то бывал и я.

Серверный nonce (RFC 9449, §8 и §9) снимает зависимость от часов клиента: опорой становятся часы
сервера. Nonce — это значение, которое выдаёт сервер. Клиенту не нужно понимать, что внутри. Он просто
кладёт это значение в поле nonce следующего доказательства. Сервер принимает доказательство, если
недавно сам выдавал этот nonce. Теперь свежесть обеспечивает nonce, а iat нужен только для справки.
Платить за это приходится первым запросом. При первом обращении у клиента ещё нет nonce. Поэтому
первый запрос не проходит — так задумано. И в самом отказе должно быть всё, что нужно клиенту, чтобы
исправиться:

HTTP/1.1 401 Unauthorized

WWW-Authenticate: DPoP error="use_dpop_nonce", error_description="nonce is required in the DPoP proof", algs="ES256 PS256"

DPoP-Nonce: eyJ7S_zG8SxF0SbEjb-5aYW7Xbt9Jd_kKdIaI4BbX2o

Клиент читает заголовок DPoP-Nonce, подписывает с этим nonce новое доказательство и повторяет
запрос. В любом следующем ответе может прийти свежий DPoP-Nonce, и клиент подставит его в
следующий запрос. Поэтому клиент, который постоянно отправляет запросы, больше никогда не увидит 401.
А клиент, который несколько минут простаивал, увидит его ещё раз. Этот круг показан на схеме ниже.
Клиентская библиотека должна делать его обычным циклом, а не обрабатывать как ошибку.

Две вещи в этом обмене отняли у нас много времени, и криптография тут ни при чём. Первая — код
статуса. Возьмём token endpoint — эндпоинт /token, где сервер авторизации выдаёт токены. О плохом
доказательстве там, по RFC 9449, сообщают ответом 400 с ошибкой в теле, как о любой другой ошибке
этого эндпоинта. А сервер ресурсов сообщает о том же ответом 401 с ошибкой в заголовке
WWW-Authenticate, как в RFC 6750. Ошибка одна, статусов два, и какой из них правильный, зависит от
роли эндпоинта. У нашего сервера есть два своих эндпоинта, которые принимают токены доступа
(защищённые ресурсы). Сначала каждый из них сам исправлял статус, в своём блоке catch. Потом мы
перенесли это решение в общий обработчик ошибок: теперь он выбирает статус по маршруту. С тех пор
третий защищённый ресурс без этого правила уже не добавишь.

Вторая — CORS. Это правила, по которым браузер решает, что скрипт может прочитать в ответе с другого
origin (другой схемы, хоста или порта). Заголовки WWW-Authenticate и DPoP-Nonce из такого ответа
браузер скрипту не покажет, если сервер не перечислил их в Access-Control-Expose-Headers. Без них
одностраничное приложение видит ответ 401, но не видит, что в нём. И повторить запрос не сможет
никогда. Спецификация упоминает это мимоходом. А ведь именно от этого зависит, заработает ли DPoP в
браузере вообще.

За nonce прячется вопрос проектирования. По-моему, он интереснее, чем выглядит в спецификации: откуда
сервер знает, какие nonce он недавно выдавал? Очевидный ответ — хранить их. Это плохой ответ. Nonce
уходит с каждым ответом. Значит, каждому вызову API пришлось бы сначала что-то записать в хранилище и
только потом вернуть результат. Поэтому мы nonce не храним, а вычисляем. Nonce для каждой минуты
получается через HKDF — стандартную функцию, которая выводит новое значение из секрета. На вход ей
идут секрет сервера и номер минуты в роли соли (дополнительного входного значения). Доказательство
принимается, если его nonce совпадает с nonce любой из пяти минут вокруг текущей:

const STEP = 60;

function nonceFor(secret: Uint8Array, step: number): string {

const salt = new Uint8Array(8);

new DataView(salt.buffer).setBigUint64(0, BigInt(step));

return base64url(hkdfSync('sha256', secret, salt, '', 32));

}

export function nextNonce(secret: Uint8Array): string {

return nonceFor(secret, Math.floor(Date.now() / 1000 / STEP) + 1);

}

export function acceptsNonce(secret: Uint8Array, nonce: string): boolean {

const now = Math.floor(Date.now() / 1000 / STEP);

let matched = 0;

for (const offset of [-2, -1, 0, 1, 2]) {

matched |= Number(constantTimeEqual(nonceFor(secret, now + offset), nonce));

}

return matched === 1;

}

В этом коде три сознательных решения. Первое: сервер выдаёт nonce следующей минуты, а не текущей.
Даже если клиент получил его в конце минуты, у него остаётся три-четыре минуты, пока nonce не выпадет
из окна. Второе: acceptsNonce сравнивает nonce со всеми пятью кандидатами и объединяет результаты.
На первом совпадении код не останавливается. Поэтому по времени проверки нельзя понять, какая минута
совпала. Третье: ничего не хранится. Значит, любая реплика (копия сервера) с тем же секретом
принимает nonce, выданный другой репликой. А перезапуск ничего не забывает — забывать нечего.

Платить за это приходится отзывом. Вычисленный nonce нельзя отозвать досрочно, и он не привязан к
конкретному клиенту. Это нормально. Nonce показывает только, что доказательство свежее. Одноразовым
он доказательство не делает. Одноразовость обеспечивает jti. Люди путают эти две вещи — и начинают
верить, что nonce защищает от повторов. Не защищает.

Ловить повторы — значит обращаться к базе на каждом запросе

Чтобы ловить повторы по jti, сервер должен помнить идентификатор каждого принятого доказательства,
для каждого клиента. Помнить его нужно столько, сколько доказательство ещё может быть принято: пять
минут, а если время в доказательстве опережает часы сервера — до десяти. Получается чтение и затем
запись на каждом вызове API с токеном. Допустим, ваш сервер ресурсов — автомасштабируемая функция без
собственного состояния. Тогда это хранилище — новая зависимость, которой до DPoP не было. Обычно её
впервые замечают, когда тревогу начинает поднимать именно хранилище повторов.

Хочется отключить обнаружение повторов. Тут я хочу честно рассказать, что мы решили. В нашем сервере
есть переключатель, который его отключает. Он задаётся в конфигурации, которую сервер читает при
запуске. Ни в консоли администратора, ни в API управления его нет — намеренно. Он нужен для двух
случаев. Первый — набор тестов на соответствие стандарту, который нарочно повторяет одно и то же
доказательство. Второй — человек, который разбирается с проблемой и повторяет запрос. Ни то ни другое
не администрирование.

Представьте переключатель, который выключает защиту, а discovery (документ, где сервер перечисляет
свои возможности) продолжает её рекламировать. Именно такие переключатели щёлкают в два часа ночи во
время инцидента и забывают вернуть обратно. А в два часа ночи люди работают как раз в консоли.
Поэтому там его нет.

Без обнаружения повторов вы теряете гораздо меньше, чем «DPoP перестаёт работать». Пусть атакующий
перехватил запрос целиком — токен и доказательство. Он может повторять ровно этот запрос, с тем же
методом и URL, пока nonce или iat доказательства не устареют. Пять минут повторять один
GET /orders — реальная утечка, но не катастрофа. Пять минут повторять один POST /transfers —
совсем другой разговор. Допустим, ваш API идемпотентен (повтор запроса ничего не меняет) и в основном
отдаёт данные. Тогда вы вполне можете решить, что хранилище повторов того не стоит. Я бы не делал так
по умолчанию. И не дал бы переключать это туда-обратно с веб-страницы.

Главная польза DPoP — защита refresh-токена, а не токена доступа

В спецификации есть пункт, который легко пропустить. Некоторые клиенты публичные: они не могут
доказать token endpoint, кто они. У публичного клиента к ключу привязывается и refresh-токен. У
конфиденциального клиента, который доказать это может, — нет. Его refresh-токен и так привязан к
учётным данным клиента, а их сервер проверяет при каждом использовании refresh-токена. В нашей
реализации это одно условие: если метод аутентификации клиента — none, refresh-токен получает тот
же jkt, что и токен доступа.

По-моему, это самый сильный аргумент за DPoP в браузере. Сильнее всего, что касается токена доступа.
В одностраничном приложении токен доступа живёт пятнадцать минут, а refresh-токен — днями. И хранится
он в браузере. Именно там его скорее всего и найдёт атакующий: в утёкшей резервной копии профиля
браузера или на общем компьютере. До DPoP украденный из браузера refresh-токен открывал доступ к
аккаунту на всё время, пока ротация давала ему жить.

Теперь добавим DPoP и неизвлекаемый ключ WebCrypto: страница может им пользоваться, но скопировать его
из браузера нельзя. Тогда украденный refresh-токен — просто строка. Обменять её на новые токены
нельзя: для обмена нужно доказательство, подписанное ключом, а ключ не покидал origin. Ротация
по-прежнему важна. Но теперь она ловит атакующего, которого уже остановили, а не того, кто уже внутри.

Та же идея работает и на шаг раньше — через dpop_jkt. Это параметр запроса авторизации. Он
привязывает код авторизации к ключу ещё до того, как код выдан. Тогда код, перехваченный между
редиректом и обменом на токен, нельзя обменять с другим ключом. Правда, от большей части этого и так
защищает PKCE. А наш сервер заполняет dpop_jkt сам, когда доказательство приходит вместе с pushed
authorization request (PAR — клиент заранее отправляет параметры авторизации прямо на сервер). Так что
на практике это ничего не стоит. Это не повод включать DPoP. Но и не повод его выключать.

Когда DPoP стоит своей цены, а когда нет

Платить приходится за три вещи, и подпись — не из их числа. Первое: клиенту нужен ключ, место, где
его хранить, и доказательство на каждый запрос. В браузере ключ должен быть неизвлекаемым
CryptoKey в IndexedDB, в нативном приложении — лежать в хранилище ключей платформы. Если ключ можно
извлечь, вы незаметно теряете большую часть того, за что заплатили. Второе: клиент должен считать
use_dpop_nonce поводом повторить запрос, а не сбоем. А сервер должен открыть два заголовка для
вызовов с другого origin, иначе повтор не случится никогда. Третье: кто-то должен отвечать за
хранилище повторов, где записи живут пять минут. Или за решение его не заводить.

Что вы получаете взамен. DPoP окупается в трёх случаях. Токены идут через инфраструктуру, которую вы
не контролируете. Публичный клиент держит долгоживущий refresh-токен. Или один утёкший токен может
натворить столько бед, что фразу «четырнадцать минут для любого, кто его найдёт» нельзя произнести
вслух на ревью.

Теперь обратный случай: ваш собственный конфиденциальный клиент, который работает на сервере. Он ходит
в свой же API по TLS, токены живут пять минут, аудитория узкая. Тут я бы не стал возиться. Токены,
привязанные через mTLS (TLS, где сертификат предъявляет и клиент), или просто короткоживущие
bearer-токены дают то же самое, а кода на клиенте нужно меньше.

Чего я не знаю — так это что браузеры сделают дальше. Есть Device Bound Session Credentials (DBSC):
сначала это было предложение Chrome, теперь это черновик IETF. DBSC пытается дать cookie то же
свойство — привязку к ключу. Ключ там держит сам браузер, а не страница. Это как раз та граница,
которую DPoP пересечь не может. Если DBSC приживётся и научится работать с API-токенами, большую часть
сегодняшнего клиентского кода DPoP возьмёт на себя платформа. Если же DBSC останется функцией только
для cookie, DPoP так и будет единственной привязкой к отправителю, доступной браузерному клиенту без
клиентского сертификата. Я бы строил в расчёте на второй вариант. А первому бы порадовался.

Хотите посмотреть, как это устроено в работающем сервере? В FoxAuth DPoP сначала включается для всего
сервера, а затем для каждого клиента — атрибутом клиента dpop_bound_access_tokens. Требовать ли
nonce — это настройка. А переключатель повторов — тот самый, о котором шла речь выше; в консоли его нет
намеренно. Справочник настроек перечисляет параметры,
справочник эндпоинтов показывает, какие эндпоинты принимают
доказательство, а модель угроз фиксирует, что должна остановить
каждая проверка.

Все статьиПопробовать самому
