Аутентификация и сессии: куки, JWT и хеширование паролей
Серверная сессия — непрозрачный id в HttpOnly-куке, состояние в хранилище; JWT — подписанные клеймы без состояния. Для браузера выбирай куки-сессии, хешируй пароли через scrypt + timingSafeEqual, закрепляй алгоритм JWT, иначе один поддельный заголовок захватит все аккаунты.
Ты выкатываешь «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 шлёт всегда, но тогда требует Secure — SameSite это твоя первая линия против 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) без закрепления алгоритма. Какую атаку это открывает?
- 01Почему JWT в localStorage хуже, чем непрозрачный id сессии в HttpOnly-куке, и по оси кражи, и по оси отзыва?
- 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-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.