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

8311
Что такое PKCE и зачем одноразовый ключ протоколу OAuth

Механизм, придуманный для мобильных приложений, стал обязательной защитой публичных OAuth-клиентов и добрался до ИИ-агентов.

image

Две программы на одном смартфоне объявляют операционной системе, что обе умеют открывать ссылку вида myapp://callback. Пользователь жмёт «Войти через Google», браузер послушно возвращает код авторизации обратно в приложение, и тут система встаёт перед выбором, кому этот код отдать. Строго говоря, никакой монетки она не бросает. Просто получатель оказывается неопределённым, два приложения заявили одну схему, и кто из них настоящий, системе неоткуда узнать. RFC 7636 описывает сценарий сухо и добавляет фразу, от которой холодеет спина. Такое уже видели в реальности.

Ровно от этой дыры и придумали PKCE. Расшифровывается как Proof Key for Code Exchange, читается «пикси» (да, как лесная фея, авторы спецификации не лишены чувства юмора). Расширение к OAuth 2.0, опубликованное в сентябре 2015 года. Под текстом подписались Нат Сакимура из Nomura Research Institute, Джон Брэдли из Ping Identity и Навин Агарвал из Google. Идея у троих вышла обманчиво простой, и именно простота десять лет спустя сделала её обязательной для публичных клиентов и рекомендованной для всех остальных.

Сначала разберёмся с самим кодом авторизации

OAuth решает одну неприятную задачу. Приложению нужен доступ к вашим данным на чужом сервисе, но пароль ему показывать нельзя. Самый ходовой сценарий называется Authorization Code Grant, и крутится он вокруг короткоживущего кода.

Выглядит так. Приложение отправляет вас на сервер авторизации с параметром response_type=code, идентификатором клиента и адресом возврата. Вы логинитесь, видите экран «Acme хочет читать вашу почту», нажимаете «Разрешить». Сервер перенаправляет вас назад и прикладывает к адресу не токен, а именно код. Короткий, одноразовый, сам по себе бесполезный. Дальше код меняют на токен на отдельном эндпоинте, и запрос идёт мимо браузера. У серверного приложения его шлёт backend, у мобильного или одностраничного приложения само приложение, но в любом случае не через редирект.

Зачем такая возня вместо того, чтобы сразу выдать токен? Затем, что у конфиденциального клиента финальный обмен подтверждается секретом, client_secret. Перехваченный код без секрета ничего не стоит. Красивая схема. Ровно до того момента, когда секрета у клиента нет.

Почему мобильному приложению нельзя доверить секрет

Серверное приложение прячет client_secret в переменных окружения, и до него не дотянуться. А вот публичный клиент хранить тайны не умеет. Нативное приложение под Android разбирается декомпилятором за вечер. У одностраничного приложения весь исходный код лежит прямо в браузере, открой инструменты разработчика и читай. Положить секрет в Keychain на iOS или Keystore на Android лучше, чем ничего, но на устройстве с джейлбрейком даже защищённое хранилище превращается в открытую книгу.

Вывод неприятный. Любой client_secret, зашитый в мобильное или браузерное приложение, по факту публичен. А раз он публичен, то перехваченный код авторизации снова становится пропуском в аккаунт. Тот самый шаг с обменом, который защищал серверного клиента, для публичного не работает вовсе.

Атака, ради которой всё затевалось

Вернёмся к смартфону. На мобильных платформах приложения общаются с браузером через кастомные схемы ссылок, что-нибудь вроде myapp://callback. Когда пользователь заканчивает вход, сервер авторизации возвращает код именно по такой ссылке. Беда в том, что несколько приложений могут заявить одну и ту же приватную схему, и тогда, как формулирует RFC 8252, получатель ссылки становится неопределённым. Вредоносная программа при установке тихо сообщает системе через пару строк в Info.plist или AndroidManifest.xml, что она тоже готова открывать myapp://. Никакого взлома, никакого ограбления сервера в стиле «Одиннадцати друзей Оушена». Программа просто сидит и ждёт.

Дальше всё решает маршрутизация операционной системы. Перепадёт ссылка вредоносу, и тот забирает код, меняет на токен, входит под вашим именем. RFC 7636 рисует картинку схематично и в первом же разделе отвечает на немой вопрос читателя «а это вообще бывает или академическая паранойя?» холодным замечанием, что атаку наблюдали в дикой природе. Не теория.

Как работает PKCE

Решение элегантное до неприличия. Перед стартом приложение генерирует случайную строку, code_verifier. Длина от 43 до 128 символов из урезанного набора (буквы, цифры и четыре знака препинания), источник только криптостойкий генератор. Никакого Math.random(), иначе вся затея превращается в декорацию.

Из проверочной строки приложение считает производную, code_challenge. По методу S256 формула одна строчка, BASE64URL(SHA256(code_verifier)). Хэш необратим, из челленджа исходную строку не достать. Именно челлендж приложение и отправляет на сервер авторизации в самом начале, вместе с параметром code_challenge_method=S256. Сервер запоминает челлендж и привязывает к выданному коду.

Аналогия, которая правда помогает. Представьте навесной замок и единственный ключ к нему. Замок (челлендж) вы оставляете швейцару на входе у всех на виду, ключ (верификатор) держите в кармане. Кто угодно может сфотографировать замок, толку ноль, ведь открыть его без ключа нельзя. Когда приходите забирать своё, достаёте ключ и доказываете, что замок ваш. Вор, перехвативший фотографию замка, остаётся ни с чем.

На финальном шаге, обменивая код на токен, приложение прикладывает исходный code_verifier в запросе к token endpoint. Сервер берёт его, заново считает SHA256, кодирует, сравнивает с тем челленджем, что запомнил вначале. Совпало, выдаёт токены. Не совпало, возвращает ошибку invalid_grant и закрывает дверь. Перехватчику из прошлого раздела это рушит всю схему. Код у него, может, и есть, а вот верификатора нет. Ключевая деталь в том, что верификатор ни разу не проходил через браузерный редирект и не уходил по кастомной схеме, где его могло бы подхватить чужое приложение. Он всплывает только на token endpoint, уже внутри запроса на обмен, по защищённому каналу.

Из спецификации удобно взять каноничный пример. Верификатор dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk после хэширования превращается в челлендж E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM. Связь односторонняя, обратно не раскрутить.

plain против S256, и где спрятаны грабли

Метода два, и они неравноценны.

Метод Что уходит как challenge Защита
plain сам code_verifier без изменений только от узкого сценария, когда атакующий видит ответ авторизации, но не видит запрос
S256 SHA-256 от верификатора, кодированный base64url стойкая, исходную строку не восстановить даже при утечке запроса

Тонкость, которую часто проскакивают. plain прикрывает один частный случай, когда злоумышленник перехватил ответ сервера, но не видел исходный запрос с челленджем. Стоит запросу авторизации где-то засветиться в логах или утечь, и при методе plain верификатор раскрыт, ведь там он и есть челлендж. Поэтому спецификация требует S256 везде, где клиент технически способен его посчитать, а сам S256 обязателен к поддержке на стороне сервера (Mandatory To Implement). plain оставлен лишь для совсем зажатых в ресурсах сред.

Главная подлянка зарыта в умолчании. Если параметр code_challenge_method не прислать вовсе, сервер по спецификации считает метод равным plain. То есть забывчивость разработчика молча понижает защиту до самого слабого варианта.

Три атаки, которые легко спутать

В разговорах про PKCE постоянно мешают три разных сценария, и от этой каши родятся неверные выводы. Развести их стоит раз и навсегда.

  • Перехват кода (code interception). Чужое приложение на устройстве забирает настоящий код пользователя через ту самую кастомную схему. Классика мобильного мира, ради неё PKCE и задумывали.
  • Инъекция кода (code injection). Атакующий подсовывает легитимному клиенту код из своей собственной транзакции, чтобы привязать чужую сессию к своему аккаунту. Тут PKCE спасает даже конфиденциального клиента, ведь код намертво привязан к челленджу.
  • Понижение (downgrade). Сервер умеет PKCE, но не требует его во всех потоках. Отсутствие code_challenge превращается в удобный атакующему флаг, и проверка верификатора тихо отключается. Лечится одним правилом, сервер обязан отклонять запрос без челленджа.

PKCE это не аутентификация, и это надо понять правильно

Частое заблуждение. Раз PKCE с секретом, значит, он заменяет client_secret. Нет. Механизм не аутентифицирует клиента и публичного в конфиденциального не превращает. client_secret доказывает личность («я то самое приложение»), а верификатор доказывает владение конкретной транзакцией («я тот, кто начал именно этот вход»). Утёкший однажды секрет позволяет выдавать себя за приложение вечно. Верификатор же одноразовый, свежий на каждый запрос, украденный вчера сегодня бесполезен.

Отсюда вывод, который удивляет даже опытных бэкендеров. PKCE полезен и тем клиентам, у кого секрет есть. client_secret прикрывает токен-эндпоинт, а PKCE прикрывает поток авторизации и закрывает отдельную атаку, инъекцию кода. Привязка кода к верификатору такую подмену рубит на корню.

А как же state и nonce

Тут легко уехать в другую крайность и решить, будто PKCE отменяет все прочие защиты вокруг редиректа. Не отменяет. Параметр state по-прежнему носит состояние приложения через редирект и традиционно прикрывает CSRF, а в OpenID Connect за свежесть ID-токена отвечает nonce.

Дальше начинается интересное. RFC 9700 допускает опираться на CSRF-защиту самого PKCE, но только когда клиент заранее убедился, что сервер авторизации PKCE поддерживает. Получается живое противоречие между буквой и практикой. Формально PKCE умеет прикрыть и CSRF, а вендоры вроде Auth0 всё равно советуют держать state как обязательный, не взаимозаменяемый с остальными слой. И не на пустом месте советуют. Академическое исследование Янга ещё в 2016 году разобрало 405 сайтов на OAuth и нашло, что 61 процент вообще не реализовали защиту от CSRF, а среди тех, кто использовал state, больше половины обращались с ним с ошибками. Протокол гибкий, и потому его упорно внедряют небезопасно.

Чего PKCE не умеет

Честный разговор. PKCE стережёт код авторизации и ровно на этом его полномочия кончаются. Выданный токен он не охраняет никак. Утащил инфостилер access-токен прямо из памяти браузера, и пикси разводит руками, её смена давно закончилась.

За живучесть уже выданных токенов отвечает другой класс механизмов, sender-constrained токены. Черновик OAuth 2.1 поверх обычных bearer-токенов предлагает привязку через DPoP (RFC 9449) или взаимный TLS (RFC 8705), чтобы краденый токен не сработал с чужого устройства. Туда же ложится требование к refresh-токенам публичных клиентов, они должны быть либо привязаны к отправителю, либо одноразовыми с ротацией. PKCE, DPoP и ротация решают разные задачи на разных отрезках, и подменять один другим бессмысленно.

2025 и 2026, пикси выросла из рекомендации

Десять лет PKCE оставался хорошим тоном для публичных клиентов и необязательным излишеством для остальных. Эпоха закончилась, но закончилась в два разных шага, и их полезно не смешивать.

Первый шаг уже сделан. В январе 2025 года вышел RFC 9700, свод лучших практик безопасности OAuth со статусом BCP. Документ собрал уроки целого десятилетия. Для публичных клиентов PKCE он делает обязательным, для конфиденциальных рекомендует (защита от инъекции кода стоит того), а про метод предписывает использовать такой, что не раскрывает верификатор, и сегодня этому условию отвечает только S256.

Второй шаг ещё в пути. Черновик OAuth 2.1 радикализует подход и встраивает PKCE в обычный поток с кодом, требуя code_challenge и code_verifier и оставляя узкую лазейку разве что конфиденциальным OIDC-клиентам с корректным nonce. Заодно из стандарта вычищены неявный поток (implicit grant), отдававший токены прямо в URL, и поток с паролем (ROPC). Только это пока именно черновик. Актуальная версия draft-ietf-oauth-v2-1-15 опубликована 2 марта 2026 года и истекает 3 сентября 2026 года, отправка в IESG намечена ориентировочно на конец 2026 года. Финального RFC под номером 2.1 ещё нет.

На практике это значит, что закладываться на единое поведение провайдеров рано. Auth0 поддерживает только S256, Okta в своих инструкциях ведёт разработчика к S256, а Microsoft Entra ID до сих пор документирует и plain, и S256, и при отсутствии метода трактует челлендж как plaintext. Один протокол, три разных дефолта.

Самый свежий поворот сюжета подкинул бум ИИ-агентов. Спецификация авторизации Model Context Protocol строит свою модель доступа на OAuth 2.1 с PKCE, правда с оговорками. Авторизация там опциональна и касается прежде всего транспортов поверх HTTP, а серверам на STDIO положено получать учётные данные из окружения. Но для HTTP-агентов выбор связки показателен. Многие из них живут в средах, где аккуратно спрятать статический секрет неудобно, и одноразовый верификатор оказывается ровно тем, что нужно. Механизм из 2015 года, заточенный под смартфоны, неожиданно подпёр совсем другой мир, где у входа порой вообще нет человека, нажимающего «Разрешить».

Как это должно выглядеть в 2026 году

Если свести всё к рабочему чек-листу для разработчика, получается короткий и довольно жёсткий список.

  • Только S256. plain в современном проекте поводов остаться не имеет.
  • Верификатор рождает криптостойкий генератор, и каждый запрос авторизации получает свежий.
  • Верификатор хранится временно и привязан к сессии, после обмена кода его стирают.
  • Token endpoint отвергает код без корректного верификатора, а сервер авторизации не принимает запрос без челленджа.
  • В мобильном приложении и SPA никакого client_secret.
  • Для нативных приложений, где платформа позволяет, предпочтительнее заявленные HTTPS-редиректы (App Links на Android, Universal Links на iOS) вместо кастомных схем. Домен проверяется системой, и проблема неопределённого получателя исчезает.
  • state и nonce остаются на местах, PKCE их не отменяет.

Короткий разбор частых вопросов

Нужен ли PKCE, если у меня уже есть client_secret?

Да. Секрет защищает обмен кода на токен, но не сам поток авторизации. PKCE закрывает инъекцию кода авторизации, от которой client_secret не спасает. RFC 9700 рекомендует механизм и конфиденциальным клиентам, а черновик OAuth 2.1 требует его практически для всех.

Можно ли обойтись методом plain?

Технически да, по умолчанию сервер даже сам выберет plain, если метод не прислать. Практически нет. plain прикрывает лишь узкий случай, когда атакующий видел ответ, но не запрос. Стоит запросу засветиться, и верификатор раскрыт. S256 обязателен к поддержке на сервере, и для нового кода альтернатив ему нет.

Какой длины должен быть верификатор и как его генерировать?

От 43 до 128 символов из набора A-Z, a-z, 0-9 и знаков -._~. Рабочий рецепт прямо из спецификации, взять 32 случайных октета от криптостойкого генератора и закодировать в base64url без padding, на выходе строка в 43 символа с запасом энтропии.

PKCE заменяет параметр state?

В общем случае нет. state носит состояние приложения и традиционно прикрывает CSRF. RFC 9700 разрешает положиться на CSRF-защиту PKCE, но лишь если клиент убедился, что сервер авторизации PKCE поддерживает. На практике state обычно оставляют как отдельный слой, и крупные провайдеры это советуют.

Защищает ли PKCE токены после выдачи?

Нет. PKCE стережёт только код авторизации. Украденный access-токен он не спасёт. За живучесть токенов отвечают sender-constrained механизмы, DPoP (RFC 9449) или взаимный TLS (RFC 8705), плюс ротация refresh-токенов.

В чём разница между PKCE и nonce из OpenID Connect?

nonce связывает выданный ID-токен с конкретным запросом и бьёт по повторному воспроизведению на уровне приложения. PKCE защищает код авторизации от перехвата и инъекции. Частично их зоны пересекаются на защите от инъекции, но взаимозаменяемыми они не становятся.

Чем заменить кастомную URI-схему на мобильных?

Заявленными HTTPS-редиректами. App Links на Android и Universal Links на iOS привязывают адрес возврата к домену, который система проверяет на владение. Чужое приложение перехватить такую ссылку уже не может, и сценарий с неопределённым получателем закрывается на корню. RFC 8252 рекомендует именно этот путь.

PKCE обязателен прямо сейчас или это пока теория?

Для публичных клиентов фактически обязателен, так предписывает вышедший RFC 9700 и так устроены актуальные SDK. Для всех без исключения его сделает обязательным OAuth 2.1, но тот ещё в статусе черновика. Так что де-факто да, де-юре по финальному стандарту пока нет.

Забавная деталь напоследок. В консоли любого современного провайдера сидит безобидный переключатель «Require PKCE», и немалая часть разработчиков щёлкает его в положение «вкл» просто потому, что название обещает усиление безопасности. А ведь за фейским именем прячется аккуратный криптографический трюк против совершенно прозаичной беды, неопределённого получателя ссылки на мобильной платформе. Спецификации десять лет. Понадобились накопленный опыт эксплуатации и свод RFC 9700, чтобы вежливое «желательно» доросло до требования. И последний штрих к портрету. Подпись Джона Брэдли стоит и под RFC 7636 две тысячи пятнадцатого года, и под RFC 9700 две тысячи двадцать пятого. Один человек закрыл дыру и десять лет спустя возвёл заплатку в ранг закона.

AI-челлендж по пентесту CyberED × Standoff Hackbase
01
Собери агента
02
Выпусти на полигон
03
Посмотри кто победит
В челлендж
10–17 сентября Публичный рейтинг

Рекламодатель
ООО «СерчИнформ»
ИНН: 7704306397
searchinform.ru↗
ИИ-ассистент СерчИнформ