Веб
Ошибки с JWT и токенами: подпись, срок жизни, хранение и отзыв
В API с токенами экспертиза проверяет алгоритм подписи, стойкость ключа, срок жизни и состояние после выхода. Типичные ошибки и их исправление в Laravel, Node и Flutter.
· 10 мин. чтения

Мобильное приложение или SPA должно в каждом запросе передавать серверу доказательство «кто я». Чаще всего это токен: JWT (JSON Web Token) или случайная строка, хранящаяся на сервере. Токен удобен, но по сути это ключ: у кого он в руках, тот и действует от имени пользователя. Поэтому то, как токен создаётся, проверяется, хранится и отзывается, — центральная часть безопасности.
В этой статье разберём типичные ошибки с JWT и другими токенами, как проверить их самостоятельно и как исправить в Laravel (Sanctum, Passport), Node и Flutter.
JWT вкратце
JWT состоит из трёх частей, разделённых точкой: header.payload.signature. Header и payload — обычный JSON в кодировке Base64URL, то есть не зашифрованы. Их может прочитать кто угодно:
echo 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9' | base64 -d
# {"alg":"HS256","typ":"JWT"}
Подпись гарантирует, что токен не изменён. В HS256 подпись ставится общим секретным ключом (secret), в RS256 или ES256 — закрытым ключом и проверяется открытым.
Первый вывод: не кладите в payload секретные данные (пароль, номер паспорта, список внутренних ID). Токен остаётся на устройстве, в браузере и в логах.
Почему это важно для экспертизы
На экспертизе специалист перехватывает токен и ищет ответы на несколько вопросов: можно ли обойти подпись, можно ли подобрать ключ, сколько живёт токен, работает ли он после выхода, где он хранится? При проверке мобильного приложения хранение токена на устройстве в незашифрованном виде записывается отдельной находкой — на экспертизе нашего собственного приложения находка высокого уровня «чувствительные данные хранятся во внутренней памяти без шифрования» относилась именно к этому классу.
Ошибка 1: alg: none и подмена алгоритма
В стандарте JWT есть алгоритм none — токен без подписи. Некоторые старые или неправильно настроенные библиотеки доверяют значению alg в заголовке: если атакующий укажет "alg":"none" и уберёт подпись, токен будет принят.
Второй вариант — путаница алгоритмов (algorithm confusion). Сервер использует RS256, но функция проверки берёт алгоритм из токена. Атакующий меняет alg на HS256 и подписывает токен через HMAC открытым ключом сервера. Если библиотека использует открытый ключ как HMAC-секрет, поддельный токен окажется валидным.
Решение одно: жёстко задайте разрешённый алгоритм на сервере и не доверяйте alg из токена.
// Node, jsonwebtoken
const payload = jwt.verify(token, publicKey, {
algorithms: ['RS256'],
issuer: 'https://api.your-domain.uz',
audience: 'mobile-app',
});
В PHP современные версии firebase/php-jwt требуют ключ вместе с алгоритмом:
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
$decoded = JWT::decode($token, new Key($publicKey, 'RS256'));
Проверка: возьмите свой тестовый токен, замените header на {"alg":"none","typ":"JWT"}, измените ID пользователя в payload, оставьте подпись пустой (header.payload.) и отправьте в API. Ответ должен быть 401.
Ошибка 2: слабый secret
Если secret для подписи HS256 короткий или угадываемый (secret, 123456, название проекта, пример из документации), его можно подобрать офлайн по одному настоящему токену. Для этого есть общедоступные инструменты вроде hashcat, и сервер при этом ничего не замечает. Найдя ключ, атакующий может выпускать токены от имени любого пользователя, включая администратора.
Исправление:
- Secret — не менее 256 случайных бит:
openssl rand -base64 48. - Он хранится не в коде и не в репозитории, а в переменной окружения или хранилище секретов — см. статью об управлении секретами.
- Если токен проверяют несколько сервисов, используйте асимметричный алгоритм (
RS256,ES256): проверяющим выдаётся только открытый ключ. - Если ключ утёк или есть подозрение — смените его. Это аннулирует все токены, поэтому продумайте процедуру смены заранее.
Ошибка 3: бессрочные токены
JWT без поля exp или API-токен без срока действия работает вечно. Потерян телефон, токен попал в лог, сотрудник уволился — он продолжает работать.
Практичный подход: короткоживущий access token (например, от 15 минут до часа) и более долгий, но управляемый refresh token. Сроки выбирайте под бизнес-требования; главное — чтобы предел был и сервер его проверял. Вместе с exp сверяйте iss (кто выпустил) и aud (для кого).
Ошибка 4: токен хранится не там
В браузере. К localStorage и sessionStorage имеет доступ любой JavaScript на странице. Одна XSS на сайте — и токен украден. По возможности храните токен браузерного приложения в cookie с атрибутами HttpOnly, Secure, SameSite — JavaScript его не прочитает. Не забудьте при этом о защите от CSRF.
В мобильном приложении. Хранение токена в SharedPreferences, NSUserDefaults или обычном файле — частая находка экспертизы. Правильное место — Android Keystore и iOS Keychain. Во Flutter:
const storage = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
);
await storage.write(key: 'access_token', value: token);
// logout
await storage.deleteAll();
Подробнее: Keystore и Keychain.
В логах. Заголовок Authorization или ?token= в URL попадают в access-логи. Не передавайте токен в query string и маскируйте заголовки в логах.
Ошибка 5: refresh token не ротируется
Refresh token живёт долго, поэтому это самая ценная цель. Если он всё время одинаковый, украденная копия бесконечно работает параллельно с владельцем.
Правильная схема — rotation: при каждом обновлении старый refresh token аннулируется и выдаётся новый. Если уже использованный refresh token приходит повторно, это признак кражи: отзовите все токены этой цепочки и попросите пользователя войти заново.
async function refresh(oldToken) {
const record = await db.refreshToken.findUnique({ where: { hash: sha256(oldToken) } });
if (!record || record.expiresAt < new Date()) throw new Unauthorized();
if (record.usedAt) {
await db.refreshToken.updateMany({ where: { familyId: record.familyId }, data: { revoked: true } });
throw new Unauthorized();
}
await db.refreshToken.update({ where: { id: record.id }, data: { usedAt: new Date() } });
return issueTokens(record.userId, record.familyId);
}
Обратите внимание: в базе хранится не сам refresh token, а его хеш.
Ошибка 6: выход не отзывает токен
JWT самодостаточен (stateless): сервер проверяет его, не обращаясь к базе. Поэтому «выход» часто лишь удаляет токен из приложения, а на сервере он работает до истечения срока. На экспертизе это проверяют просто: токеном, полученным до выхода, делают запрос после выхода.
Варианты:
- Сделайте access token короткоживущим, а при выходе отзывайте refresh token на сервере.
- Добавьте в payload
jti(ID токена) и храните отозванные в кэше (Redis) до истечения их срока. - При смене пароля отзывайте все сессии и токены пользователя.
Проверка:
TOKEN=<токен до выхода>
curl -s -X POST https://api.your-domain.uz/v1/logout -H "Authorization: Bearer $TOKEN"
curl -s -o /dev/null -w "%{http_code}\n" https://api.your-domain.uz/v1/me \
-H "Authorization: Bearer $TOKEN"
# ожидается: 401
Laravel: заметки о Sanctum и Passport
API-токены Sanctum — не JWT: это случайная строка, хранящаяся в базе (personal_access_tokens) в виде хеша. Поэтому проблем вроде путаницы алгоритмов здесь нет, а удаление токена действует сразу. Но проверьте две вещи:
// config/sanctum.php — по умолчанию срока нет (null)
'expiration' => 60 * 24, // в минутах
// logout: удалить текущий токен
$request->user()->currentAccessToken()->delete();
Для очистки просроченных записей запланируйте команду php artisan sanctum:prune-expired. Для SPA используйте режим Sanctum на основе cookie-сессии — токен не попадает в хранилище браузера.
Passport — полноценный OAuth2-сервер, выпускающий JWT access-токены с подписью RS256. Явно задайте сроки и защитите файлы ключей:
Passport::tokensExpireIn(now()->addMinutes(30));
Passport::refreshTokensExpireIn(now()->addDays(14));
storage/oauth-private.key не должен попадать в репозиторий, права на файл должны быть ограничены (chmod 600), а production-ключ передаваться через переменную окружения.
Короткий план самопроверки
- Войдите тестовым аккаунтом и получите токен. Декодируйте payload: есть ли в нём секретные данные, есть ли
exp,iss,aud? - Отправьте запрос с токеном
alg: noneи с изменённой подписью — ожидается401. - Измените ID пользователя в payload — ожидается
401. - Отправьте запрос с просроченным токеном.
- После выхода попробуйте старые access и refresh токены.
- Используйте один refresh token дважды.
- Посмотрите, где хранится токен: в браузере — в DevTools → Application, в мобильном приложении — в его хранилище.
Токен — это ключ. Чем короче он живёт, чем надёжнее хранится и чем быстрее отзывается, тем меньше вреда при его краже.
Чек-лист
- Сервер жёстко задаёт разрешённый алгоритм,
noneне принимается. - Secret для
HS256— не менее 256 случайных бит и не в коде; по возможности асимметричный ключ. - У каждого токена есть срок; проверяются
exp,iss,aud. - В payload нет секретных данных.
- В браузере токен в
HttpOnlycookie, в мобильном приложении — в Keystore/Keychain. - Refresh token меняется при каждом обновлении, повторное использование обнаруживается.
- Выход и смена пароля отзывают токены на сервере.
- В Sanctum задан
expiration, ключи Passport защищены.
Итог
Ошибки с токенами редко требуют сложной атаки: чаще не задан срок, выход не работает или токен лежит в незащищённом месте. Всё это проверяется несколькими запросами — так и делает экспертиза.
Хотите проверить механизм аутентификации и токенов до экспертизы? Оставьте заявку на сканирование — Claude AI агент проанализирует код и настройки, а наш специалист подтвердит находки и поможет с исправлением.


