Bearer-токен работает слишком просто: кто его получил, тот и авторизован. Именно поэтому утечки токенов регулярно превращаются в реальные инциденты — от CI/CD до облачных хранилищ. В новом переводе от команды Spring АйО рассмотрим, как DPoP меняет эту модель, привязывая токен к ключу клиента, зачем это нужно backend-разработчику и как поднять рабочую реализацию на Keycloak и Quarkus.


DPoP — одно из самых заметных нововведений в сфере IAM (управление идентификацией и доступом) за последние годы. При этом многие backend-разработчики либо вообще о нём не слышали, либо не до конца понимают, что именно он меняет. В этой статье я разберу, что такое DPoP, какую проблему он решает, и пройду путь до рабочей реализации на Keycloak и Quarkus.

Что такое DPoP?

DPoP (Demonstration of Proof-of-Possession) — это механизм безопасности OAuth 2.0, описанный в RFC 9449. Его ключевая цель проста: криптографически привязать access token к клиенту, который его запросил. Тогда даже если токен перехватят, другой клиент не сможет им воспользоваться.

В традиционной модели Bearer-токенов авторизованным считается любой, кто владеет токеном. DPoP меняет эту модель: чтобы использовать токен, клиент должен дополнительно доказать владение соответствующим приватным ключом.

Проблема: Bearer-токены и риск «нашёл — значит твоё»

Bearer-токены — это токены, которые передаются в HTTP-заголовке Authorization и принимаются сервером без какой-либо дополнительной проверки того, кто именно их предъявляет. RFC 6750 прямо говорит, что владение токеном является единственным критерием авторизации. Это означает: любая сторона, получившая токен, может действовать так, будто она и есть легитимный клиент.

Комментарий от Михаила Поливаха

Вообще это проблема стара как мир. Поэтому собственно говоря в своё время и придумали долгоживущие refresh token-ы, и короткоживущие access token-ы. Условно, раньше кража access token-а конечно была делом страшным, но как минимум он жил довольно короткое время. Сейчас же появляется подход, который позволяет эту проблему в целом решить.

Это не значит, что refresh token-ы не нужны, у них всё равно остается своя функция, но я просто к тому, что в мире cybersecurity эту проблему уже давно разными способами пытались решить.

И это не теоретический риск. Реальные инциденты раз за разом показывали: украденный Bearer-токен напрямую превращается в несанкционированный доступ.

  • Атака на цепочку поставок Codecov (2021): злоумышленники, проникшие в CI/CD-процесс Codecov, собрали токены, которые клиенты хранили в переменных окружения. Эти токены потенциально давали доступ к приватным репозиториям сотен организаций, включая HashiCorp, которая подтвердила, что пострадала.

  • Утечка OAuth-токенов GitHub (2022): OAuth-токены, принадлежавшие Heroku и Travis CI, были украдены, что позволило атакующим получать списки приватных репозиториев и доступ к метаданным репозиториев в десятках GitHub-организаций, включая npm.

  • Инцидент с SAS-токеном Microsoft (2023): исследовательская AI-команда Microsoft случайно опубликовала в GitHub-репозитории чрезмерно «широкий» SAS-токен. Этот токен позволял получить доступ к 38 ТБ внутренних данных.

Комментарий от Михаила Поливаха

Ситуация, когда ключи случайно пушатся в VCS это просто база. Особенно при работе с AI Агентами.

Просто для понимания, по исследованиям техрадара (речь не про JugRu и их техрадар) около 29 млн. секретов было слито в открытый доступ в 2025 году на GitHub. Это количество год за годом растёт, кстати ускоренными темпами с AI.

Общая черта всех этих случаев в том, что токен оказался у посторонних — и без каких-либо /препятствий был использован в другом контексте и другим актором. Это возможно из-за базового допущения модели Bearer-токенов: тот, кто предъявил токен, и есть авторизованный актор. Модель проверяет, у кого токен в руках, а не кому токен принадлежит.

Как работает DPoP?

DPoP требует, чтобы клиент с каждым запросом отправлял DPoP Proof JWT. Этот proof подписывается приватным ключом клиента и содержит следующие клеймы:

  1. htm и htu (HTTP-метод и URL): ограничивают proof конкретным endpoint’ом, не позволяя использовать proof, созданный для одного ресурса, в рамках другого.

  2. jti (JWT ID): каждый proof содержит уникальный идентификатор. Сервер фиксирует уже использованные jti и отклоняет любые попытки повторного использования. 

    Комментарий от Михаила Поливаха:

    Это кстати не просто так сделано. То есть даже если утечёт DPoP-шный JWT, то его нельзя будет переиспользовать, т.к. сервер уже его видел. Нужен будет новый пруф, новый токен, с новым JTI.

    А новый токен сгенерировать будет нельзя - подписать нечем. Приватный ключ известен только клиенту.

  3. iat (Issued At): указывает, когда был сгенерирован proof, позволяя серверу задавать окно валидности и отклонять устаревшие proof’ы.

  4. ath (Access Token Hash): указывает, с каким access token связан proof.

Поток выглядит так:

  1. Клиент генерирует асимметричную пару ключей.

  2. При запросе токена (т.е. к запросу /token в рамках Authorization Server-а) клиент отправляет DPoP proof JWT, в заголовке которого содержится публичный ключ (JWK).

  3. Сервер авторизации выпускает DPoP-привязанный access token, содержащий отпечаток JWK (cnf.jkt).

  4. При обращении к защищённому ресурсу клиент отправляет теперь не один, а уже 2 заголовка:
    – Authorization: DPoP <access_token>
    – DPoP:

  5. Resource server:

    – проверяет подпись proof (DPoP хедер)
    – сверяет, что публичный ключ proof соответствует cnf.jkt в токене
    – валидирует htm, htu, iat, jti
    – проверяет клейм ath, привязывающий proof к access token

В этой модели одной кражи токена уже недостаточно. Атакующий не сможет генерировать валидные proof’ы без приватного ключа, что ограничивает потенциальное злоупотребление уже перехваченным, ещё не использованным proof’ом и только в пределах узкого окна его валидности. Для сравнения: в Bearer-модели украденный токен даёт неограниченный доступ до истечения срока действия. DPoP не устраняет кражу токенов как класс, но делает украденные токены принципиально сложнее в эксплуатации.

Настройка DPoP в Keycloak

Для этой статьи я использую Keycloak (v26.5.5) в качестве провайдера идентификации. Это open-source продукт, широко применяемый на практике, и он предоставляет встроенную поддержку DPoP с простой конфигурацией.

DPoP появился в Keycloak как preview-функция в версии 23.0.0 и стал официально поддерживаться, начиная с версии 26.4, работая «из коробки» без дополнительной настройки клиента. Если клиент отправляет DPoP proof при запросе токена, Keycloak валидирует его и включает отпечаток ключа в выдаваемый токен. Для поведения по умолчанию больше ничего не требуется.

Однако если вы хотите принудительно включить DPoP для конкретного клиента — то есть Bearer-токены для ресурсов этого клиента больше не будут приниматься, — выполните следующие шаги:

Шаг 1: в консоли администратора Keycloak перейдите в нужный realm и выберите клиента в меню Clients.

Шаг 2: на вкладке Settings найдите секцию Capability config.

Шаг 3: включите тогл “Require DPoP bound tokens”.

При включённой этой опции клиент обязан прикладывать DPoP-proof к каждому запросу токена. Запросы без валидного proof будут отклоняться, а Bearer-токены не будут приниматься для доступа к ресурсам этого клиента.

DPoP в действии на Quarkus

Чтобы посмотреть DPoP на практике, я собрал приложение на Quarkus с защищёнными REST-endpoint’ами и протестировал их с помощью скрипта k6. Полный исходный код доступен на GitHub.

Комментарий от Михаила Поливаха

Без паники. Spring Security OAuth2, конечно же, тоже поддерживает DPoP токены.

Настройка проекта

Приложение использует Quarkus 3.32.2 со следующим ключевым расширением: OpenId Connect. Quarkus предоставляет расширения для OpenID Connect и управления access token’ами OAuth 2.0, с фокусом на получении, обновлении и проксировании токенов.

<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-oidc</artifactId>
</dependency>

Свойство quarkus.oidc.auth-server-url задаёт базовый URL сервера OpenID Connect (OIDC), которым в данном случае выступает инстанс Keycloak:

quarkus.http.port=8180
quarkus.oidc.auth-server-url=http://localhost:8080/realms/master
quarkus.oidc.client-id=dpop-demo
quarkus.oidc.token.authorization-scheme=dpop

Ключевая строка здесь — quarkus.oidc.token.authorization-scheme=dpop. Это свойство сообщает расширению Quarkus OIDC, что нужно ожидать схему Authorization: DPoP и выполнять полный процесс проверки DPoP proof согласно RFC 9449. Это включает валидацию подписи proof, клеймов htm, htu, ath, а также привязку по отпечатку cnf между токеном и публичным ключом proof.

Защищённые endpoint’ы

Приложение публикует три endpoint’а под путём /api, и все они требуют аутентификации. Каждый endpoint возвращает имя вызывающего и тип токена (Bearer или DPoP), проверяя наличие клейма cnf в JWT:

@Path("/api")
@Authenticated
public class ProtectedResource {

    private final JsonWebToken jwt;

    public ProtectedResource(JsonWebToken jwt) {
        this.jwt = jwt;
    }

    @GET
    @Path("/user-info")
    @Produces(MediaType.TEXT_PLAIN)
    public String getUserInfo() {
        return buildResponse();
    }

    @POST
    @Path("/user-info")
    @Produces(MediaType.TEXT_PLAIN)
    public String postUserInfo() {
        return buildResponse();
    }

    @POST
    @Path("/list-users")
    @Produces(MediaType.TEXT_PLAIN)
    public String listUsers() {
        return buildResponse();
    }

    private String buildResponse() {
        return "Hello, %s! Token type: %s".formatted(
                jwt.getName(),
                jwt.containsClaim("cnf") ? "DPoP" : "Bearer"
        );
    }
}

Наличие и GET, и POST для /user-info, плюс отдельный endpoint /list-users, сделано намеренно. Это позволяет показать, как клеймы DPoP proof (htm и htu) ограничивают использование токена конкретным HTTP-методом и URL.

Защита от повторного воспроизведения через jti-фильтр

Как упоминалось выше, расширение Quarkus OIDC берёт на себя основную проверку DPoP. Однако защита от повторного использования jti не входит в этот процесс, поскольку отслеживание уже использованных значений требует серверного состояния, а это выходит за рамки stateless-валидации токенов.

Я добавил минимальный @ServerRequestFilter, который фиксирует jti каждого proof и отклоняет повторное использование:

@Singleton
public class DpopJtiFilter {

    private final Set<String> usedJtis = ConcurrentHashMap.newKeySet();

    @ServerRequestFilter
    public Optional<Response> checkJti(ContainerRequestContext ctx) {
        String dpopHeader = ctx.getHeaderString("DPoP");
        if (dpopHeader == null || dpopHeader.isBlank()) {
            return Optional.empty();
        }

        String[] parts = dpopHeader.split("\\.");
        if (parts.length != 3) {
            return Optional.empty();
        }

        try {
            String payloadJson = new String(
                    Base64.getUrlDecoder().decode(parts[1]));
            String jti = extractJti(payloadJson);
            if (jti != null && !usedJtis.add(jti)) {
                return Optional.of(Response.status(Response.Status.UNAUTHORIZED)
                        .type(MediaType.TEXT_PLAIN)
                        .entity("DPoP proof replay detected: jti '%s' has already been used"
                                .formatted(jti))
                        .build());
            }
        } catch (Exception e) {
            // Let Quarkus OIDC handle malformed proofs
        }

        return Optional.empty();
    }

    // ...
}

В этом примере я использую in-memory ConcurrentHashMap, чтобы упростить демо. В production-среде вы бы применили распределённое хранилище вроде Redis или Infinispan, чтобы отслеживать использованные значения jti между несколькими инстансами приложения и делать eviction по TTL, согласованному с окном валидности proof.

Стоит отметить, что Keycloak уже выполняет защиту от повторного использования jti на уровне сервера авторизации. Внутри его DPoPReplayCheck используется SingleUseObjectProvider, который опирается на реплицируемый кэш Infinispan. Когда DPoP proof приходит на token endpoint, Keycloak хэширует jti вместе с URI запроса с помощью SHA-1 и сохраняет результат с TTL, вычисленным из клейма iat proof. Если тот же proof отправляется повторно, вызов putIfAbsent не проходит, и запрос отклоняется.

Однако эта защита покрывает только запросы к самому Keycloak. После того как выпущен DPoP-привязанный токен, resource server отвечает за собственное отслеживание jti. Украденный proof можно воспроизвести против приложения на Quarkus, и Keycloak этого не увидит. Именно поэтому я добавил jti-фильтр на уровне resource server, создавая двухслойную защиту: Keycloak защищает token endpoint, а фильтр — endpoint’ы приложения.

Тестирование с k6

В репозитории есть тестовый скрипт k6 (k6/dpop-test.js), который прогоняет полный DPoP-флоу. Запускается так:

k6 run k6/dpop-test.js

Скрипт выполняет семь HTTP-вызовов последовательно. Первый запрос получает DPoP-привязанный токен в Keycloak, следующие три — «happy path» запросы (по одному на каждый endpoint), а последние три проверяют сценарии с ошибками. Разберём подробнее, что происходит «под капотом» на уровнях Keycloak и Quarkus.

Запрос токена (Keycloak)

Перед любым доступом к ресурсам скрипт запрашивает DPoP-привязанный access token:

  • Скрипт генерирует пару EC-ключей (P-256) через WebCrypto API.

  • Создаёт DPoP proof JWT, нацеленный на token endpoint Keycloak (htm: POST, htu: .../protocol/openid-connect/token), и подписывает его приватным ключом. Публичный ключ встраивается в заголовок proof в поле jwk.

  • Отправляет POST на token endpoint с заголовком DPoP и учётными данными пользователя (grant_type=password).

  • Keycloak валидирует DPoP proof (подпись, структуру, клеймы), затем выдаёт access token, содержащий клейм cnf (confirmation) с thumbprint (SHA-256) публичного ключа клиента. Это привязывает токен к конкретной паре ключей. Обратите внимание на typ: DPoP и поле cnf.jkt в выданном токене:

{
  "typ": "DPoP",
  "azp": "dpop-demo",
  "sub": "830783f9-ab1b-4c41-9c23-fa6a335de1bc",
  "cnf": {
    "jkt": "8iU6dz7Uclsxek7kgyreJc8sc2LjZIbFqtUUFpWKZIc"
  },
  "scope": "email profile",
  "preferred_username": "hakdogan"
}

GET /user-info (Happy Path)

  1. Скрипт создаёт свежий DPoP proof для GET /api/user-info с новым jti, актуальным iat и ath, вычисленным из SHA-256-хэша access token. Payload proof выглядит так:

{
  "jti": "6f0bf628-309d-489b-9243-38ed169e1d8c",
  "htm": "GET",
  "htu": "http://localhost:8180/api/user-info",
  "iat": 1772897361,
  "ath": "3yFPVhSab16gaSgMAFtZCgm7GXpBMx5t3ZYCeuWqT0w"
}

2. Он отправляет GET /api/user-info с Authorization: DPoP ... и DPoP: ....

3. jti-фильтр Quarkus проверяет jti proof по хранилищу использованных значений. Это новый jti, поэтому запрос проходит дальше.

4. Расширение Quarkus OIDC валидирует DPoP proof в соответствии с RFC 9449 (Раздел 7.1), где эта обязанность возлагается на resource server. Оно проверяет подпись proof, подтверждает, что htm совпадает с GET, htu совпадает с URL запроса, ath совпадает с хэшем токена, и что thumbprint cnf в токене соответствует публичному ключу proof. Все проверки проходят.

5. Endpoint читает клейм cnf из токена, определяет его как DPoP-токен и отвечает:

HTTP 200: Hello, hakdogan! Token type: DPoP

Скрипт повторяет тот же процесс для POST /user-info и POST /list-users, каждый раз создавая новый proof, соответствующий целевому методу и URL. Оба запроса возвращают 200 с тем же ответом.

GET /user-info (Replay-атака)

  1. Скрипт отправляет в точности тот же proof, который использовался в happy path-запросе.

  2. jti-фильтр Quarkus проверяет jti и находит его уже присутствующим в хранилище использованных значений. Запрос отклоняется ещё до валидации OIDC:

HTTP 401: DPoP proof replay detected: jti '...' has already been used

Примечание: сообщение об ошибке выше включает значение jti в демонстрационных целях, чтобы было легко увидеть, что именно поймал фильтр. В production-окружении не стоит раскрывать внутренние значения клеймов в ответах об ошибках. Достаточно общего 401 Unauthorized без тела или минимального сообщения вроде «invalid DPoP proof», чтобы избежать утечки информации.

POST /user-info (несоответствие метода — htm)

  1. Скрипт создаёт новый proof с htm: GET, нацеленный на /api/user-info, но отправляет его как POST-запрос.

  2. jti-фильтр Quarkus пропускает запрос (новый jti).

  3. Расширение Quarkus OIDC сравнивает htm из proof (GET) с фактическим методом запроса (POST). Они не совпадают. Запрос отклоняется:

HTTP 401

POST /list-users (несоответствие URL — htu)

  1. Скрипт создаёт новый proof, нацеленный на POST /api/user-info.

  2. Затем отправляет запрос на POST /api/list-users.

  3. jti-фильтр Quarkus пропускает запрос (новый jti).

  4. Расширение Quarkus OIDC сравнивает htu из proof с фактическим URL запроса. Они не совпадают. Запрос отклоняется:

HTTP 401

Все семь проверок проходят:

✓ Запрос токена успешен
✓ GET /user-info возвращает 200
✓ POST /user-info возвращает 200
✓ POST /list-users возвращает 200
✓ Replay-атака возвращает 401
✓ Несоответствие htm возвращает 401
✓ Несоответствие htu возвращает 401

Для сравнения: если те же запросы отправить как обычные Bearer-токены без DPoP proof, все они завершатся 200. Сценарии replay, несоответствия метода и несоответствия URL останутся незамеченными, потому что проверять будет нечего — proof отсутствует. Именно этот разрыв и закрывает DPoP.

Заключение

Bearer-токены следуют простому правилу: авторизован тот, у кого токен на руках. DPoP меняет это, привязывая каждый токен к криптографической паре ключей и требуя свежий подписанный proof для каждого запроса. Одной кражи токена становится недостаточно.

Экосистема IAM движется в этом направлении. Провайдеры идентификации вроде Keycloak и фреймворки вроде Quarkus уже дают встроенную поддержку DPoP, что упрощает внедрение. Bearer-токены никуда не исчезнут, но для доступа к чувствительным ресурсам внедрение DPoP становится уже не столько выбором, сколько необходимостью.

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.