Аутентификация в деталях: JWT, OAuth 2.0 и хеширование паролей
В краткой версии ты видел рабочий минимум: выдать пару токенов, положить refresh в httpOnly cookie, проверять подпись на каждый запрос. Этого хватает для демо, но не хватает для понимания, где именно прячутся дыры. Аутентификация — самая атакуемая часть любого веб-сервиса: пароли утекают из баз, токены крадутся XSS-ом, OAuth-флоу обходятся поддельными редиректами. В этой главе разберём механику JWT до байтов, научимся читать атаки как пентестер и построим полноценную систему: ротация refresh-токенов, чёрный список в Redis, вход через Google с PKCE.
JWT изнутри: header, payload, signature
Заголовок раздела «JWT изнутри: header, payload, signature»JWT — три сегмента, разделённые точками, каждый — Base64URL без паддинга:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjQyLCJyb2xlIjoiYWRtaW4iLCJleHAiOjE3MzU2MDAwMDB9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJVadQssw5c└──────── header ────────┘ └──────── payload ────────┘ └──────── signature ────────┘Декодируем header и payload (это просто JSON, декодируется кем угодно — JWT не шифрует данные, а только подписывает):
// header{ "alg": "HS256", "typ": "JWT" }// payload (claims){ "sub": 42, "role": "admin", "iat": 1735596400, "exp": 1735600000 }Подпись считается по формуле: HMACSHA256(base64(header) + "." + base64(payload), secret). Сервер, получив токен, пересчитывает подпись своим секретом. Не сошлась — токен отбрасывается. Ключевое свойство: изменить хоть один байт payload (например, role: "user" → "admin") невозможно без пересчёта подписи, а секрет знает только сервер.
В payload не клади ничего секретного: пароль, email в открытом виде, PII. Base64 — это кодирование, а не шифрование: любой декодер покажет всё. Удобно разбирать структуру интерактивно на jwt.io — тот же формат header/payload/signature, только с живым дебаггером подписи.
HS256 vs RS256: модель доверия
Заголовок раздела «HS256 vs RS256: модель доверия»Два алгоритма, две разные архитектуры доверия:
- HS256 (HMAC) — симметричный: один и тот же секрет подписывает и проверяет. Просто, быстро, но секрет должен знать каждый сервис, который проверяет токены. Микросервис, которому нужно только читать токены, получает право и подделывать их.
- RS256 (RSA) — асимметричный: приватный ключ подписывает на сервере авторизации, публичный ключ проверяет везде. Публичный ключ можно раздать хоть в README — подписать им нельзя.
// выпуск — только на auth-сервисе, приватный ключ живёт в секрет-хранилищеconst token = jwt.sign({ sub: user.id, role: user.role }, privateKey, { algorithm: 'RS256', expiresIn: '10m', audience: 'orders-api', // aud проверяется при верификации issuer: 'auth.example.com',});
// проверка — на любом сервисе, публичного ключа достаточноconst payload = jwt.verify(token, publicKey, { algorithms: ['RS256'], // <<< жёстко фиксируем список алгоритмов audience: 'orders-api', issuer: 'auth.example.com',});Для монолита HS256 нормален. RS256 нужен, когда токен проверяют несколько независимых сервисов или фронтенд-микросайты — классика enterprise и SSO.
Атаки на JWT и защита
Заголовок раздела «Атаки на JWT и защита»alg: none
Заголовок раздела «alg: none»Старейшая атака: header { "alg": "none" }, пустая подпись, а в payload — role: "admin". Уязвимые библиотеки принимали такой токен как валидный.
# вариант атакующего (подпись пустая, но точка на конце обязательна)eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOjQyLCJyb2xlIjoiYWRtaW4ifQ.Защита — одна строка: явный список алгоритмов при верификации. Все современные библиотеки по умолчанию отклоняют none, но дешёвые кастомные реализации до сих пор всплывают в пентестах. Разбор типовых ошибок при работе с JWT (включая alg: none и подмену алгоритма) дан в RFC 8725 — JWT Best Current Practices.
kid-инъекция и подмена алгоритма
Заголовок раздела «kid-инъекция и подмена алгоритма»kid (key id) в header указывает, каким ключом проверять. Если сервер подставляет kid в путь файла или SQL-запрос без санитайза:
{ "alg": "HS256", "kid": "../../../dev/null" }или классический трюк: если сервер хранит публичный ключ для RS256 и позволяет в header сказать alg: HS256, атакующий подписывает токен публичным ключом сервера как HMAC-секретом — а сервер проверяет его же. Защита: алгоритм выбирается сервером по типу ключа, kid — строгий маппинг kid → ключ из конфига, никакой строковой подстановки.
Слабый секрет
Заголовок раздела «Слабый секрет»HS256 с секретом secret123 взламывается за секунды перебором по словарю: подпись пересчитывается для каждого кандидата, совпала — секрет найден. Инструменты вроде hashcat делают это на GPU со скоростью миллионы вариантов в секунду. Правила: секрет ≥ 32 байта энтропии (openssl rand -base64 32), секреты не в коде и не в git — только в env/секрет-хранилище, ротация при малейшем подозрении на утечку, отдельные секреты для окружений.
Access/refresh: ротация и отзыв
Заголовок раздела «Access/refresh: ротация и отзыв»Два токена решают конфликт: access короткий (5–15 минут) ходит с запросами, refresh длинный (7–30 дней) живёт в httpOnly cookie и тратится только на /auth/refresh.
// POST /auth/refresh — ротация: каждый refresh используется один разapp.post('/auth/refresh', async (req, res) => { const token = req.cookies.refreshToken; if (!token) return res.sendStatus(401);
const payload = jwt.verify(token, REFRESH_SECRET); // refresh-токены храним в Redis: user:{id}:refresh → jti, TTL = срок жизни const stored = await redis.get(`user:${payload.sub}:refresh`); if (stored !== payload.jti) { // токен не найден или jti не совпал — возможен кража: гасим всю сессию await redis.del(`user:${payload.sub}:refresh`); return res.sendStatus(401); }
const newJti = crypto.randomUUID(); await redis.set(`user:${payload.sub}:refresh`, newJti, 'EX', 7 * 24 * 3600);
setRefreshCookie(res, signRefresh(payload.sub, newJti)); // новый refresh res.json({ accessToken: signAccess(payload.sub) }); // новый access});Ротация с хранением jti в Redis даёт два свойства: отзыв (DEL ключа — сессия мёртва, даже если refresh не истёк) и детект кражи (старый refresh переиспользовали — значит, его скопировали, инвалидируем всё). Логаут — тот же DEL плюс access-токен в чёрный список на оставшееся время жизни:
// чёрный список access: SET jti "1" EX (exp - now)await redis.set(`bl:${accessJti}`, '1', 'EX', payload.exp - Math.floor(Date.now() / 1000));// middleware проверяет: EXISTS bl:{jti} → 401Хранение токенов на клиенте: cookie vs localStorage
Заголовок раздела «Хранение токенов на клиенте: cookie vs localStorage»Дилемма: где держать access-токен на фронтенде.
| Хранилище | XSS | CSRF |
|---|---|---|
localStorage |
JS читает — украден одним скриптом | не подвержен (куки нет) |
httpOnly cookie |
JS не читает — украсть сложнее | подвержен, нужен SameSite + защита |
Практичная схема: access в памяти приложения (переменная модуля, теряется при перезагрузке страницы — не страшно, берём новый по refresh), refresh в httpOnly + Secure + SameSite cookie. Никогда не клади access в localStorage — любой XSS превращается в угон сессии.
Сессии vs JWT
Заголовок раздела «Сессии vs JWT»Сессия — состояние на сервере (Redis: sessionId → user), клиент хранит лишь идентификатор в cookie. JWT — состояние в токене, сервер хранит ничего (почти). Таблица выбора:
| Критерий | Сессии | JWT |
|---|---|---|
| Отзыв | мгновенный — удалил запись | нужен чёрный список/хранение jti |
| Масштабирование | общий Redis для всех нод | подпись проверяется локально |
| Нагрузка на хранилище | каждый запрос — lookup | только refresh/отзыв |
| Данные в токене | только id | claims доступны клиенту |
Модный вывод 2026 года: гибрид. Refresh-токены — фактически сессии (храним в Redis, отзываем), access — чистый JWT (быстрая локальная проверка, короткая жизнь). Это и есть схема, которую мы построили выше.
OAuth 2.0: гранты
Заголовок раздела «OAuth 2.0: гранты»OAuth решает задачу делегированного доступа: пользователь даёт приложению право действовать от его имени у провайдера (Google, GitHub), не сообщая приложению свой пароль. Ниже — практические гранты; каноническое описание флоу — RFC 6749, The OAuth 2.0 Authorization Framework.
Authorization Code + PKCE — единственный грант для публичных клиентов (SPA, мобильные). PKCE (Proof Key for Code Exchange) закрывает атаку перехвата кода: клиент генерирует случайный code_verifier, шлёт его хеш (code_challenge) на авторизацию, а при обмене кода на токен предъявляет исходный verifier — сервер сверяет хеш. Перехвативший код без verifier бесполечен.
Client Credentials — машина к машине: бэкенд сервиса A получает токен для сервиса B по client_id + client_secret. Без пользователя вообще.
Device Authorization Grant — ТВ, консоли, CLI: устройство показывает код, пользователь вводит его на другом устройстве с браузером. Устройство поллит токен, пока пользователь не подтвердит.
OIDC: идентичность поверх OAuth
Заголовок раздела «OIDC: идентичность поверх OAuth»OAuth отвечает «чему приложение может делать», но не «кто пользователь». OpenID Connect добавляет поверх: id_token — JWT с claims пользователя (sub, email, name, picture, email_verified), и стандартный эндпоинт userinfo, отдающий профиль по access token. Практические правила: валидируй подпись id_token ключами провайдера (JWKS-эндпоинт, кэшируй ключи), проверяй iss, aud, exp, nonce против того, что слал в запросе. sub — стабильный идентификатор, email может смениться.
Login with Google: по шагам
Заголовок раздела «Login with Google: по шагам»- В Google Cloud Console создаёшь OAuth Client ID (тип Web application), указываешь
redirect_uri:https://app.example.com/api/auth/google/callback. Секрет — в env. - Кнопка «Войти через Google» ведёт на:
https://accounts.google.com/o/oauth2/v2/auth ?client_id=...&redirect_uri=...&response_type=code &scope=openid%20email%20profile &state=<csrf-state>&code_challenge=<sha256(verifier)>&code_challenge_method=S256- Google редиректит на callback с
?code=...&state=.... Сверяешьstateс сессионным — иначе это CSRF. - Backend меняет код на токены (секрет — только на сервере!):
const tokens = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ code, client_id, client_secret, grant_type: 'authorization_code', redirect_uri: CALLBACK, code_verifier: storedVerifier, }),}).then(r => r.json());// tokens: { access_token, id_token, refresh_token? }- Декодируешь и валидируешь
id_token(подпись по Google JWKS,aud= твой client_id,iss= accounts.google.com). Поsubищешь локального пользователя; нет — создаёшь. Выдаёшь свою пару access/refresh — и дальше работаешь со своей системой, как после обычного логина.
SSO кратко: когда у компании несколько внутренних сервисов, вместо N логинов ставится Identity Provider (Keycloak, Okta, Entra ID). Сервисы доверяют общим ключам, пользователь логинится один раз — это тот же OIDC, только iss общий и есть протоколы федерации (SAML для легаси).
Хеширование паролей
Заголовок раздела «Хеширование паролей»Пароли хешируются, а не шифруются: шифрование обратимо, хеш — нет. Требования к алгоритму: медленный (замедляет перебор), с солью (уникальной per-пароль, ломает rainbow-таблицы), устойчивый к GPU/ASIC.
Argon2id — победитель Password Hashing Competition, текущий стандарт. Параметры:
import argon2 from 'argon2';
const hash = await argon2.hash(password, { type: argon2.argon2id, memoryCost: 19456, // 19 MiB — защита от GPU (параллелизм по памяти) timeCost: 2, // проходы parallelism: 1, // потоки});
const ok = await argon2.verify(user.passwordHash, password);Ориентир калибровки: верификация 100–300 мс на твоём железе. OWASP для bcrypt рекомендует cost ≥ 10; bcrypt ограничен 72 байтами ввода — длинные пароли хешируй предварительно через SHA-256. Никогда не сравнивай хеши «на глаз» через === — только через verify, иначе тайминг-атака.
Типичные ошибки и грабли
Заголовок раздела «Типичные ошибки и грабли»- Токены в localStorage. Любой XSS читает их и угоняет сессию. Access — в память, refresh — httpOnly cookie.
- Бесконечный access без отзыва. «Нам нечего скрывать» — до первого угона токена админа. Access 5–15 минут + чёрный список в Redis.
jwt.verifyбезalgorithms. Дверь дляalg: noneи подмены RS256→HS256. Всегда фиксируй список.- Секреты в репозитории.
JWT_SECRET=secretв.env, закоммиченном в git, — классика утечек..envв.gitignoreс первого коммита, секреты — в менеджер секретов, сканирование репозитория на секреты в CI (gitleaks, trufflehog). - Различающиеся ответы для «нет юзера» и «неверный пароль». Атакующий перебирает существующие email. Ответ один: «неверный email или пароль», время ответа — одинаковое.
- Refresh без ротации. Украденный refresh работает неделями. Ротация + детект переиспользования обязательны.
- Доверие к данным из JWT без проверки владения. Токен валиден ≠ пользователь имеет право на ресурс
id=123. Авторизация — отдельная проверка на каждом запросе.
Вопросы на собеседовании
Заголовок раздела «Вопросы на собеседовании»- Чем HS256 отличается от RS256 и когда что брать? HS256 — симметричный, один секрет на подпись и проверку, годится для монолита. RS256 — асимметричный, подписывает приватный ключ, проверяет публичный — нужен, когда токен проверяют независимые сервисы или нужно дать проверку без права подписи.
- Что такое атака
alg: noneи как защититься? Поддельный токен с алгоритмом none и пустой подписью; уязвимые библиотеки его принимали. Защита — явный список допустимых алгоритмов вverify. - Почему JWT нельзя просто отозвать? Подпись валидна до
exp, проверка локальная. Поэтому access короткий, а отзыв делается через чёрный список jti в Redis или хранение refresh-сессий сDEL. - Зачем PKCE и что он защищает? От перехвата кода авторизации: код без
code_verifierбесполезен. Перехватчик не знает исходный verifier, который клиент генерировал до редиректа. - Cookie vs localStorage для токенов? Cookie (httpOnly) невидимы для JS — устойчивее к XSS, но создают CSRF-риск, закрываемый SameSite. localStorage читается любым скриптом — одна XSS равна угону сессии.
- Почему Argon2id, а не SHA-256 для паролей? SHA-256 быстрый на GPU — триллионы перебора в секунду. Argon2id параметризуем по памяти и времени: ~100–300 мс на проверку делает перебор экономически бессмысленным, плюс соль блокирует rainbow-таблицы.
- Что проверить в id_token от Google? Подпись по JWKS Google,
iss= accounts.google.com,aud= твой client_id,exp,nonceпротив отправленного.
Практика
Заголовок раздела «Практика»- Реализуй
/auth/login,/auth/refresh,/auth/logoutс полной схемой: Argon2id-хеш пароля, access 10 минут, refresh 7 дней в httpOnly cookie, ротация с хранениемjtiв Redis, logout черезDEL+ чёрный список access. Критерий: после logout украденный refresh возвращает 401, повторное использование старого refresh гасит сессию. - Слабый секрет: создай в отдельной ветке JWT с секретом
secret123и взломай его перебором скриптом на 1000 частых паролей. Зафиксируй в README время взлома — это аргумент за длинные секреты. - «Вход через Google» по шагам из главы: PKCE с
code_verifierв памяти SPA,stateв cookie, обмен кода на сервере. Критерий: вход работает, а подменаstateна callback обрывается с 403. - Напиши middleware, который фиксирует список алгоритмов
['RS256'], и тест, доказывающий, что токен сalg: noneи токен, подписанный публичным ключом как HS256, отклоняются. - Сравни время ответа
/auth/loginдля существующего и несуществующего email; выровняй их (одинаковый Argon2-dummy-verify для несуществующего пользователя).