Разработчик приложил к тикету свой токен целиком — чтобы ошибку авторизации можно было воспроизвести. Ошибку починили, тикет закрыли, токен так и остался в задаче, которую видит вся команда и подрядчики.

Токен выглядит как шифротекст: длинная строка из букв, цифр и дефисов, глазами не читается. Отсюда и распространённое допущение — раз не читается, значит, закрыто, и внутрь можно положить что угодно полезное.

На самом деле это base64. Читается одной командой, ключ для этого не нужен, и содержимое видно всем, через кого проходит токен: браузеру пользователя, расширениям в нём, промежуточным прокси, системам логирования.

Что лежит внутри токена

Возьмём типичный токен:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0NDE3IiwiZW1haWwiOiJhLnBl
dHJvdkBleGFtcGxlLnJ1Iiwicm9sZSI6Im1hbmFnZXIiLCJkZXB0Ijoic2FsZXMiLCJpYXQiO
jE3NTYzMDAwMDAsImV4cCI6MTc1NjMwMzYwMH0.ip0UT1iRKo-73KGoSCfjMF-h9wx83QgJ2p
_12xPbjbc

Три части через точку: заголовок, полезная нагрузка, подпись. Декодируем среднюю:

import base64, json

payload = token.split('.')[1]
raw = base64.urlsafe_b64decode(payload + '=' * (-len(payload) % 4))
print(raw.decode())
{"sub":"4417","email":"a.petrov@example.ru","role":"manager",
 "dept":"sales","iat":1756300000,"exp":1756303600}

Никакого ключа не потребовалось. Внутри — идентификатор сотрудника, его почта, должность и подразделение. Этот набор лежит в браузере пользователя, уходит с каждым запросом и оседает в логах всех сервисов по пути.

Что подпись на самом деле гарантирует

Раз содержимое открыто, возникает резонный вопрос: а что тогда даёт третья часть токена.

Она даёт целостность, а не конфиденциальность. Подпись считается по заголовку и нагрузке с секретным ключом сервера, и без ключа пересчитать её нельзя.

Проверим это. Меняем в нагрузке manager на admin, а подпись оставляем прежнюю — новую взять неоткуда:

import base64, json, jwt

head, payload, sig = token.split('.')
data = json.loads(base64.urlsafe_b64decode(payload + '=' * (-len(payload) % 4)))
data['role'] = 'admin'
new_payload = base64.urlsafe_b64encode(
    json.dumps(data, separators=(',', ':')).encode()).rstrip(b'=').decode()

jwt.decode(f'{head}.{new_payload}.{sig}', key, algorithms=['HS256'])

Сервер такой токен не примет:

jwt.exceptions.InvalidSignatureError: Signature verification failed

Роль в нагрузке действительно стала admin — декодируется и читается. Но проверка подписи её не пропускает, потому что подпись считалась по прежним байтам. Прочитать содержимое может кто угодно, изменить — только тот, у кого есть ключ.

Три ошибки, которые из этого следуют

Первая: в токен кладут лишнее. Раз токен «всё равно передаётся», в него добавляют профиль пользователя целиком: телефон, полное имя, внутренние идентификаторы, иногда даже настройки доступа к системам. Дальше он оседает в браузере, истории запросов и логах прокси — хотя по правилам обработки персональных данных оказаться там не должен.

Правило простое: в токене — минимум для авторизации. Идентификатор, срок действия, издатель, аудитория, при необходимости роль. Всё остальное сервис получает из своей базы по идентификатору.

Вторая: токен нельзя отозвать. Это следствие самой идеи: сервер не хранит состояние, он проверяет подпись и доверяет содержимому. Пользователя уволили, права сняли — а выданный токен продолжает работать до истечения срока. Отсюда практика: короткий срок жизни у токена доступа (минуты, а не сутки) и отдельный токен обновления, который уже хранится на сервере и потому отзывается.

Если ваш токен доступа живёт сутки — увольнение сотрудника означает сутки действующего доступа. Это стоит проверить в своей системе прямо сегодня, потому что параметр обычно ставят один раз при запуске проекта и больше не смотрят.

Третья: проверяют не всё. Библиотека проверяет то, что вы попросили проверить. Срок действия exp большинство библиотек смотрит само — но убедитесь, что его не отключили через options={"verify_exp": False}. А вот издателя iss и аудиторию aud никто не проверит, пока вы их не передадите. Без aud токен, выданный для одного сервиса, примет другой: в типовой схеме один сервис аутентификации обслуживает несколько API, все они проверяют подпись одним ключом, и единственное, что отличает токен для биллинга от токена для админки, — это поле aud.

Отдельная классическая ловушка — заголовок alg. Он приходит вместе с токеном, то есть его контролирует отправитель. Библиотека, которая выбирает алгоритм проверки по этому полю, доверяет присланному токену выбирать, как его проверять. Исторически это приводило к приёму токенов с alg: none и к подмене алгоритма с асимметричного на симметричный, где в роли ключа выступал публичный. Современные библиотеки требуют указать ожидаемый алгоритм явно — и это тот параметр, который стоит проверить в своём коде:

jwt.decode(token, key, algorithms=['HS256'],
           audience='billing-api', issuer='auth.example.ru')

Если в вашем вызове нет списка algorithms или он собирается из заголовка токена — это место надо исправить.

Где хранить на клиенте

Спор здесь сводится к выбору между двумя рисками — XSS и подделкой межсайтовых запросов.

localStorage доступен любому скрипту на странице. Одна уязвимость с исполнением стороннего кода — и токен уходит целиком, вместе со всем, что в него положили.

Кука с флагами HttpOnly, Secure и SameSite скриптам недоступна, но требует защиты от подделки межсайтовых запросов, потому что браузер отправляет её сам.

Практический ориентир: короткоживущий токен доступа в памяти вкладки, токен обновления — в куке с HttpOnly. Тогда потеря первого стоит несколько минут доступа, а второй недоступен скриптам.

Что проверить у себя

Возьмите любой токен из своего приложения и декодируйте среднюю часть. Посмотрите, что там лежит, и спросите себя, готовы ли вы увидеть этот набор полей в логах стороннего сервиса.

Затем посмотрите три параметра: время жизни токена доступа, наличие проверки exp, iss и aud на сервере, явный список допустимых алгоритмов в вызове проверки.

По моему опыту, первое, что находится при таком разборе, — почта и полное имя пользователя внутри токена, потому что «фронтенду было удобно брать их оттуда, чтобы не делать лишний запрос».