Разработчик приложил к тикету свой токен целиком — чтобы ошибку авторизации можно было воспроизвести. Ошибку починили, тикет закрыли, токен так и остался в задаче, которую видит вся команда и подрядчики.
Токен выглядит как шифротекст: длинная строка из букв, цифр и дефисов, глазами не читается. Отсюда и распространённое допущение — раз не читается, значит, закрыто, и внутрь можно положить что угодно полезное.
На самом деле это 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 на сервере, явный список допустимых алгоритмов в вызове проверки.
По моему опыту, первое, что находится при таком разборе, — почта и полное имя пользователя внутри токена, потому что «фронтенду было удобно брать их оттуда, чтобы не делать лишний запрос».

