open atlas
↑ К треку
Node.js с нуля до senior NODE · 07 · 03

Аутентификация и сессии: куки, JWT и хеширование паролей

Серверная сессия — непрозрачный id в HttpOnly-куке, состояние в хранилище; JWT — подписанные клеймы без состояния. Для браузера выбирай куки-сессии, хешируй пароли через scrypt + timingSafeEqual, закрепляй алгоритм JWT, иначе один поддельный заголовок захватит все аккаунты.

NODE Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Ты выкатываешь «stateless, современную» аутентификацию: JWT в localStorage, проверка через jwt.verify(token, secret). Через три месяца встроенный тобой виджет поддержки компрометируют, и одна строка внедрённого JavaScript читает localStorage.getItem("token") и POST-ит его атакующему. Но это мелкий пожар. Большой приходит через неделю: кто-то замечает, что твоя библиотека доверяла полю alg из самого токена, прислал {"alg":"none"} с поддельным admin-пейлоадом, и твой verify вообще пропустил проверку подписи. Отзыва не было, так что каждый утёкший токен оставался валидным до своего exp. Вся утечка сводится к двум дефолтам, которые ты не подверг сомнению: где жил токен и кто решал, как его проверять.

Две модели: непрозрачная сессия vs самодостаточный токен

Прежде чем выбрать, где хранить учётку, спроси себя: можешь ли ты жить без возможности мгновенно её отозвать? Ответ определяет модель. Сервер помнит, кто ты, после логина двумя способами, и они ломаются в противоположных направлениях. Серверная сессия выдаёт непрозрачный, неугадываемый id (128+ бит случайности из CSPRNG — криптографически стойкого генератора псевдослучайных чисел) и хранит реальное состояние — id пользователя, роли, срок — на сервере в Redis или БД. Кука несёт только id; на каждый запрос сервер делает поиск. JWT несёт сами клеймы, подписанные, так что сервер проверяет подпись и доверяет пейлоаду без всякого поиска.

JWT — это три base64url-сегмента через точки: header.payload.signature. Заголовок называет алгоритм ({"alg":"HS256","typ":"JWT"}), пейлоад держит клеймы (sub, exp, iss, aud, плюс твои). Подпись — это MAC над base64url(header) + "." + base64url(payload): HMAC-SHA256 с общим секретом для HS256 или подпись RSA/ECDSA для RS256/ES256. Чтобы проверить, сервер пересчитывает подпись над полученными заголовком и пейлоадом и сравнивает.

import crypto from "node:crypto";

const b64url = (buf) => buf.toString("base64url");

function signHS256(payload, secret) {
  const header = b64url(Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })));
  const body = b64url(Buffer.from(JSON.stringify(payload)));
  const data = `${header}.${body}`;
  const sig = b64url(crypto.createHmac("sha256", secret).update(data).digest());
  return `${data}.${sig}`;
}

function verifyHS256(token, secret) {
  const [header, body, sig] = token.split(".");
  const expected = b64url(crypto.createHmac("sha256", secret).update(`${header}.${body}`).digest());
  // сравнение за постоянное время; разные длины надо отвергать, не давая утечки
  const a = Buffer.from(sig), b = Buffer.from(expected);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) throw new Error("bad signature");
  const claims = JSON.parse(Buffer.from(body, "base64url"));
  if (typeof claims.exp !== "number" || Date.now() / 1000 > claims.exp) throw new Error("expired");
  return claims;
}

Компромисс — это отзыв и размер. Id сессии тривиально отзывается — DEL session:<id>, и следующий запрос уже разлогинен, — но каждый запрос платит за поход в хранилище (доли миллисекунды на локальном Redis, реальная задержка между регионами). JWT не требует поиска, поэтому масштабируется горизонтально с нулевым общим состоянием, но ты не можешь «развыдать» его: он валиден до exp, что бы ни случилось, — вот почему украденный JWT настолько хуже украденной сессии. Провальный режим — считать JWT отзываемым: строить логаут, который «удаляет» токен, всё ещё лежащий у клиента и всё ещё принимаемый сервером.

Флаги куки — это и есть реальная граница безопасности

Где живёт учётка, решает, что может украсть один баг. Защита браузера — это набор флагов куки, и каждый закрывает конкретную дыру. HttpOnly (флаг, запрещающий доступ к куке из JavaScript) делает куку невидимой для JavaScript — document.cookie её не прочитает, — так что XSS-пейлоад не выкачает сессию, и именно поэтому токены в localStorage опасны. Secure шлёт её только по HTTPS, так что она не утечёт в даунгрейднутом или открытом запросе. SameSite управляет межсайтовой отправкой: Strict никогда не шлёт при межсайтовой навигации, Lax (современный дефолт браузера) шлёт только при навигации верхнего уровня по GET, None шлёт всегда, но тогда требует SecureSameSite это твоя первая линия против CSRF.

// Куки-сессия для браузера — сеньорский дефолт
res.setHeader("Set-Cookie",
  `__Host-sid=${sessionId}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=3600`
);

Префикс __Host- — недооценённое усиление: браузер принимает куку с префиксом __Host-, только если она Secure, имеет Path=/ и не имеет атрибута Domain — это привязывает её к точному origin и блокирует поддомен (или сетевого атакующего, способного ставить куки) от перезаписи твоей куки сессии. Скоупь жёстко: опускай Domain, пока тебе реально не нужны поддомены, ставь самый узкий Path и держи Max-Age коротким, чтобы утёкшая кука сама протухала. Честный компромисс: куки-сессии автоматически прикрепляются браузером, и именно поэтому они несут CSRF-риск, — но они HttpOnly (XSS их не прочитает) и отзываемы на сервере. JWT в localStorage не имеет CSRF-поверхности, но полностью выкачивается через XSS и неотзываем. Для браузерного приложения сеньорский дефолт — непрозрачный id сессии в куке HttpOnly; Secure; SameSite, а CSRF закрывается отдельно.

Почему это работает

Почему не просто «JWT везде, это stateless и современно»? Потому что отсутствие состояния — это цена, а не фича. Как только тебе нужен настоящий логаут, «выйти на всех устройствах», принудительный повторный вход после смены пароля или мгновенный бан скомпрометированного аккаунта, тебе нужен отзыв — а чистый JWT не умеет его без серверного денилиста, и тут ты заново ввёл тот самый поход в хранилище на каждый запрос, ради избавления от которого брал JWT, но с худшей эргономикой. JWT хороши для короткоживущих межсервисных токенов и для access-токенов в паре с отзываемым refresh-токеном. Для браузерной пользовательской сессии непрозрачный id в куке проще, меньше и отзываем по умолчанию.

Хеширование паролей: медленно, с солью, memory-hard, за постоянное время

Задумайся: если твоя БД утечёт сегодня ночью, насколько быстро атакующий сможет перебрать пароли? Ответ полностью зависит от того, какую функцию хеширования ты выбрал. Пароли не шифруют и никогда не сравнивают как открытый текст — ты хранишь одностороннюю хеш-функцию и заново выводишь её при логине. Смертельная ошибка — взять быстрый хеш. SHA-256 и MD5 спроектированы быстрыми, что здесь ровно неправильно: рядовой GPU считает миллиарды хешей SHA-256 в секунду, так что утёкшая таблица sha256(password) падает под словарём почти мгновенно, с солью или без. Защита — это медленная, солёная, memory-hard функция вывода ключа, настроенная так, чтобы каждая попытка стоила реального времени и RAM. В Node есть scrypt в node:crypto (без зависимостей); argon2 и bcrypt — другие принятые варианты.

import crypto from "node:crypto";

function hashPassword(password) {
  const salt = crypto.randomBytes(16);                       // уникальная на пользователя
  const key = crypto.scryptSync(password, salt, 64, { N: 16384, r: 8, p: 1 });
  return `scrypt$${salt.toString("hex")}$${key.toString("hex")}`;
}

function verifyPassword(password, stored) {
  const [, saltHex, keyHex] = stored.split("$");
  const salt = Buffer.from(saltHex, "hex");
  const expected = Buffer.from(keyHex, "hex");
  const actual = crypto.scryptSync(password, salt, expected.length, { N: 16384, r: 8, p: 1 });
  // за постоянное время: никогда не выходить на первом различающемся байте
  return crypto.timingSafeEqual(actual, expected);
}

Две сеньорские детали. Случайная соль на пользователя означает, что одинаковые пароли хешируются по-разному, так что одна предвычисленная радужная таблица не взломает всю таблицу, и атакующему придётся бить каждую строку отдельно. И сравнение использует crypto.timingSafeEqual, а не === или Buffer.compare: обычное сравнение возвращается, как только байты различаются, и эта разница во времени выдаёт, сколько ведущих байтов совпало, — удалённый атакующий может восстановить значение байт за байтом. timingSafeEqual всегда сканирует всю длину. Настрой фактор работы так, чтобы один хеш брал ~100–250 мс на твоём железе: достаточно медленно, чтобы миллиарды-в-секунду GPU схлопнулись до пары попыток на ядро, и достаточно быстро, чтобы логин оставался отзывчивым. Одна ловушка, если тянешься к bcrypt: он молча обрезает вход на 72 байтах, так что длинные пароли (или парольные фразы) делят префикс — предхешируй через SHA-256 или возьми scrypt/argon2.

Выбери лучший вариант

Ты строишь аутентификацию для браузерного веб-приложения. Куда положить учётку после логина?

Боевые истории: путаница alg, отсутствующий exp, нет отзыва

Большинство утечек через JWT — это не сломанная криптография, а верификатор, доверившийся атакующему. alg:none: спека определяет «незащищённый» JWT с {"alg":"none"} и пустой подписью; наивная библиотека читает алгоритм из заголовка самого токена и, видя none, пропускает проверку целиком — так что кто угодно подделает любой пейлоад. Фикс — никогда не давать токену выбирать: передавай ожидаемый алгоритм явно, например jwt.verify(token, key, { algorithms: ["HS256"] }), и отвергай всё остальное. Путаница HS256/RS256 — та же болезнь уровнем выше: если твой сервер настроен на RS256 (проверка публичным ключом), но библиотека даёт заголовку выбирать алгоритм, атакующий шлёт HS256-токен, подписанный твоим публичным ключом в роли HMAC-секрета — а он публичен, — и сервер, трактуя публичный ключ как общий секрет, валидирует подделку. Закрепление алгоритма убивает оба.

import jwt from "jsonwebtoken";

// ❌ доверяет заголовку токена — принимает alg:none и путаницу RS256/HS256
const bad = jwt.verify(token, key);

// ✅ закрепи алгоритм и проверь стандартные клеймы
const claims = jwt.verify(token, key, {
  algorithms: ["RS256"],     // никогда не давай токену выбирать
  issuer: "https://auth.example.com",
  audience: "api://orders",
  maxAge: "15m",             // ограничь время жизни, даже если exp щедрый
});
// exp проверяется самим verify; iss/aud подтверждают, что токен выпущен для *тебя*

Остальное — про время жизни и личность. Отсутствующий или непроверенный exp означает, что токены живут вечно — многие подделки через none/путаницу заодно опускают exp, — так что всегда требуй и проверяй его, и проверяй iss и aud, чтобы токен, выпущенный для другого сервиса, нельзя было воспроизвести против твоего. Нет отзыва — структурная проблема: раз JWT нельзя «развыдать», украденный годен до exp, поэтому держи access-токены короткоживущими (5–15 мин) и сочетай их с отзываемым refresh-токеном в HttpOnly-куке, который ты можешь убить на сервере. И фиксация сессии: если ты переиспользуешь один id сессии через границу логина, атакующий, подсадивший известный id до логина, после оказывается залогинен как жертва — поэтому ротируй id сессии при каждой смене привилегий (логин, повышение роли), выдавая свежий id и аннулируя старый.

Викторина

Твой сервер проверяет RS256-JWT, но вызывает jwt.verify(token, publicKey) без закрепления алгоритма. Какую атаку это открывает?

Вспомните перед уходом
  1. 01
    Почему JWT в localStorage хуже, чем непрозрачный id сессии в HttpOnly-куке, и по оси кражи, и по оси отзыва?
  2. 02
    Пройди, как alg:none и путаница HS256/RS256 каждая обходят jwt.verify, и единый фикс, останавливающий оба.
Итог

Есть две модели аутентификации с противоположными режимами отказа: серверная сессия — это непрозрачный высокоэнтропийный id в куке с реальным состоянием (пользователь, роли, срок) в Redis или БД, а JWT — это header.payload.signature, несущий подписанные клеймы, которые сервер проверяет без состояния, пересчитывая MAC (HMAC для HS256, RSA/ECDSA для RS256) над base64url-заголовком и пейлоадом. Сессии стоят похода в хранилище на каждый запрос, но мгновенно отзываемы; JWT не требуют поиска, но их нельзя «развыдать», так что украденный валиден до exp. Где живёт учётка — это и есть реальная граница: HttpOnly-кука невидима для XSS, а непрозрачный id отзываем на сервере, так что для браузерного приложения выбирай дефолтом куку сессии __Host- HttpOnly; Secure; SameSite=Lax и закрывай CSRF отдельно, а не JWT в localStorage, который один XSS выкачивает и никто не отзовёт. Никогда не храни пароли как открытый текст или быстрые хеши — SHA-256 идёт на миллиардах/сек на GPU, — а как медленный, солёный, memory-hard KDF (key derivation function — функция вывода ключа; scrypt из node:crypto или argon2/bcrypt), настроенный на ~100–250 мс за попытку, со случайной солью на пользователя и сравнением за постоянное время crypto.timingSafeEqual, помня о пределе входа bcrypt в 72 байта. И боевые истории JWT все идут от верификатора, доверившегося токену: alg:none пропускает проверку, путаница HS256/RS256 подписывает твоим публичным ключом как HMAC-секретом, отсутствующий exp делает токены бессмертными, а переиспользование id сессии через логин допускает фиксацию — так что закрепляй алгоритм, проверяй exp/iss/aud, держи токены короткими с отзываемым refresh-токеном и ротируй id сессии при каждой смене привилегий. Теперь, когда увидишь jwt.verify(token, key) без параметра algorithms, или localStorage.setItem("token", ...) — ты знаешь точный режим отказа и однострочный фикс.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.