Берегите очко смолоду
Часть 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, хранение сессий и поведение системы в отказах, чтобы доступы не разваливались в самый неудобный момент.