Берегите очко смолоду
Часть 3 — про аутентификацию и сессии: как они тихо отключаются, протекают и не отзываются.
Что здесь важно для продукта#
Пользователь видит только форму входа, но для бизнеса это граница между «человек работает со своим аккаунтом» и «кто угодно может зайти не туда». Поэтому аутентификация — не декоративный экран, а часть доверия к сервису.
Технический смысл простой: проверка личности должна быть одинаково строгой в логине, восстановлении пароля, смене почты, refresh-токенах и выходе из аккаунта. Если один из этих маршрутов слабее остальных, атакующий пойдёт туда.
Аутентификация — это место, где маленькая ошибка в одной строке открывает дверь целиком. Тут не бывает «почти безопасно»: либо проверка работает всегда, либо её фактически нет. Соберём самые показательные способы прострелить себе ногу.
Fail-open вместо fail-closed#
Сценарий, который выглядит безобидно в коде и катастрофично в проде. Подпись вебхука (или входных данных) проверяется только в том случае, если задан секрет. Нет секрета — проверка молча пропускается.
// bad: пустой секрет = проверка выключена и об этом никто не узнал
if (secret) verifySignature(req, secret);
// good: нет конфига безопасности — отклоняем запрос (а лучше падаем на старте)
if (!secret) throw new Error("WEBHOOK_SECRET is required");
verifySignature(req, secret);
Разница принципиальная. Fail-open означает, что забытая переменная окружения тихо отключает защиту, и вы об этом узнаёте из чужого отчёта. Fail-closed означает, что без секрета система отказывается работать. Безопасность должна ломаться в сторону «запретить», а не «разрешить».
Dev-обход, который доезжает до прода#
Очень соблазнительно: на не-production окружении не проверять подпись входных данных — например, initData из мессенджер-мини-аппа — «чтобы удобнее тестировать».
// bad
if (process.env.NODE_ENV === "production") verifyInitData(initData);
Что в этом плохого. Во-первых, на dev и staging теперь можно войти под любым аккаунтом — просто подсунув нужный идентификатор. Во-вторых, такие стенды регулярно торчат наружу и индексируются. В-третьих, и это главное, привязка проверок к окружению имеет свойство утекать в боевой контур: одна неверная переменная, один общий код-путь — и обход едет на прод.
Подпись и срок действия проверяются всегда, во всех окружениях. Удобство тестирования решается тестовыми данными с валидной подписью, а не выключением проверки.
Вечные refresh-токены#
Здесь была целая коллекция проблем сразу. Refresh-токены жили 30 дней, без ротации, без возможности отзыва и без детекта повторного использования. Logout не убивал доступ. Смена пароля и блокировка пользователя не инвалидировали уже выданные сессии. А access- и refresh-токен были по сути одинаковыми — в них не было даже claim’а с типом токена.
Что это значит на практике: украденный токен живёт месяц, отозвать его нельзя, а «выйти на всех устройствах» — это иллюзия кнопки. Меняешь пароль после компрометации — старые сессии продолжают работать.
Как должно быть устроено:
- ротация refresh-токенов с хранением их состояния и детектом повторного использования (token families): если старый токен из цепочки предъявили снова — вся семья отзывается, это признак кражи;
- claim
typ(access/refresh), чтобы один нельзя было использовать вместо другого; - версия или
jtiу токена, по которым можно инвалидировать; - при смене пароля или сбросе кредов — инвалидация всех сессий разом;
- состояние блокировки пользователя проверяется во ВСЕХ путях выдачи токенов, а не только при выдаче access.
// при refresh: токен уже использован → это reuse, гасим всю семью
if (stored.usedAt) {
await revokeFamily(stored.familyId);
throw new Unauthorized();
}
if (user.blocked) throw new Forbidden(); // проверяем и здесь, не только на login
await rotate(stored); // выдаём новый, старый помечаем использованным
Где лежит токен и что лежит в куке#
Админский токен хранили в localStorage и слали как Bearer. Это значит, что любой XSS — чужой скрипт, протёкшая зависимость, рекламный виджет — читает токен в одну строку. localStorage доступен JavaScript по определению.
// good: токен в httpOnly + Secure cookie, JS его не видит
res.cookie("session", token, { httpOnly: true, secure: true, sameSite: "lax" });
Отдельная история — пароль админки, который лежал в куке открытым текстом и сравнивался напрямую. Перехватил куку — получил сам пароль, а не временный токен. В куке не должно быть пароля; если уж очень нужно что-то паролеподобное, кладите HMAC от пароля и сравнивайте constant-time сравнением, чтобы не утекало через тайминг.
// bad
if (cookie.password === ADMIN_PASSWORD) allow();
// good
if (timingSafeEqual(hmac(secret, input), cookie.mac)) allow();
Не катайте свою крипту сессий#
Был самопальный токен: payload + HMAC(secret, payload), где payload — просто таймстемп. Выглядит «как настоящий». Проблема в том, что пароль пользователя в подпись не входил вообще. Значит, смена пароля никак не влияла на валидность уже выданных токенов и не мешала их подделывать тому, кто знает схему. Спасала только ротация самого секрета — то есть «сбросить вообще всех».
Состояние аутентификации должно зависеть от чего-то, что реально меняется при сбросе кредов. Иначе «сменить пароль» не значит «закрыть доступ».
Урок прямой: не изобретайте свою криптографию сессий. Берите проверенные библиотеки, привязывайте валидность токена к версии учётки/пароля, чтобы сброс действительно отзывал доступ.
Один JWT на все случаи жизни#
Свежий класс багов, который легко пропустить в большом продукте: токены разных смыслов валидируются одной и той же функцией. Например, есть пользовательский access-токен, админский токен и временный токен для merge-сценария. Все они «JWT», все подписаны похожим кодом, и рука тянется сделать один validateToken().
Проблема начинается, когда guard проверяет только подпись и срок, но не проверяет realm токена. Временный merge-токен не должен открывать пользовательский API. Пользовательский токен не должен проходить в админке. Админский токен должен отзываться отдельно, а не жить до истечения подписи.
// плохо: токен валиден криптографически, значит пускаем
const payload = verifyJwt(token, secret);
return payload;
// хорошо: валидируем не только подпись, но и назначение токена
const payload = verifyJwt(token, secret);
if (payload.typ !== "access" || payload.realm !== "user") throw new Unauthorized();
return payload;
Хорошее правило: у каждого класса токенов есть явное назначение (typ, realm, aud или отдельный issuer), отдельная проверка в guard и отдельный путь отзыва. Отсутствующее поле — это не «старый формат», а отказ.
Rate-limit там, где его забывают#
И финал. На логине и на проверке OTP не было никакого ограничения частоты. Шестизначный код — это миллион вариантов, который перебирается за минуты, если на verify нет лимита.
Тонкость, на которой спотыкаются при попытке починить: жёсткий глобальный lockout аккаунта сам становится вектором атаки. Зная чужой email, можно намеренно блокировать вход жертве — это уже DoS. Поэтому лучше прогрессивная задержка плюс лимит по IP, а не «5 ошибок — и аккаунт заморожен для всех».
Лимитировать нужно обе точки: и запрос кода (чтобы не использовали вас как бесплатную рассылку), и его проверку (чтобы код нельзя было перебрать).
На этом практическая часть серии завершается. Если свести три статьи к одной фразе: секреты не должны быть видимыми, чужой ввод не должен считаться правдой, а проверки должны работать всегда и ломаться в сторону «запретить». Остального добиваются привычкой, а не героизмом.
Если логин, восстановление доступа или сессии уже стали критичной частью продукта, их нельзя оставлять на «вроде работает». Напишите мне в Telegram или на почту — помогу проверить auth-флоу, rate limit, хранение сессий и поведение системы в отказах, чтобы доступы не разваливались в самый неудобный момент.



