Безопасная аутентификация: хеширование, JWT, OAuth2 и 2FA
Аутентификация — это проверка «ты тот, за кого себя выдаёшь». Её ломают не одним трюком, а цепочкой ошибок: пароли в открытом виде, JWT без проверки alg, OAuth без state, второй фактор по SMS. Разберём четыре столпа и где на каждом наступает реальный продакшн.
Статья практическая: PHP + WordPress, с готовыми сниппетами. Про авторизацию (current_user_can, RBAC) — отдельная тема, здесь только проверка личности.
Часть I. Хеширование паролей
1. Почему не «просто SHA-256»
Хеш пароля должен быть медленным и памятеёмким, иначе GPU-перебор сметает базу за часы.
| Метод | Вердикт | Почему |
|---|---|---|
md5(), sha1() | ⛔ | мгновенный перебор, радужные таблицы |
sha256($pass) без соли | ⛔ | один и тот же пароль у всех даёт один хеш, перебор по GPU |
sha256($pass . $salt) | ⛔ | всё ещё быстрый, перебор оптимален на GPU/ASIC |
| bcrypt / scrypt / Argon2id | ✅ | медленный по дизайну, память (Argon2/scrypt) бьёт по ASIC |
Отдельная ошибка — «секретная соль как пароль». Соль не обязана быть тайной; её задача — uniqueness (один хеш на один пароль у одного пользователя), чтобы перебор одного украденного хеша не раскрывал совпадающие пароли у других. PHP кладёт соль внутрь хеш-строки сам.
2. Правильно в PHP: password_hash / password_verify
<?php
declare(strict_types=1);
// Регистрация: сохраняем ТОЛЬКО этот хеш.
$hash = password_hash($password, PASSWORD_ARGON2ID);
// Если PHP собран без libargon2 — PASSWORD_ARGON2ID бросит ошибку.
// Безопасный выбор с фолбэком:
$algo = defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_BCRYPT;
$hash = password_hash($password, $algo);
save_password_hash($userId, $hash);<?php
// Вход: сравниваем через password_verify, НЕ через ==.
$stored = fetch_password_hash($userId); // может быть null: user не найден
if ($stored === null) {
// Имитируем работу, чтобы по времени не различать "нет такого логина".
password_verify($password, '$2y$10$'.str_repeat('.', 53));
auth_fail_generic();
} elseif (password_verify($password, $stored)) {
// ...успех...
} else {
auth_fail_generic();
}Важные детали:
password_verifyпринимает хеш и сравнивает корректно (тайминг-безопасно для хеша). Самостоятельное== $hash— ошибка.- Не обрезайте и не нормализуйте пароль перед хешированием без причины (нижний регистр ломает entropy-политику;
trimпо пробелам — спорно). Лимит PHP на длину вpassword_hash— до 4096 байт; у bcrypt реальный предел пароля — 72 байта (всё, что длиннее, молча отбрасывается). Если нужен пароль длиннее 72 байт — берите Argon2id. - Хеш бинарен/длинный: сохраняйте поле как
VARCHAR(255)минимум (Argon2-строка — ~97+ символов). НеVARCHAR(60)(это под старый phpass/bcrypt 60).
3. Миграция старых хешей
Если у вас исторически md5/sha1 или bcrypt с низким cost — не просите всех сменить пароль. Перехешируйте при входе:
<?php
if (password_verify($password, $stored)) {
// Успешный вход. Проверяем, актуален ли алгоритм/параметры.
$rehashOpts = [
'memory_cost' => 19456, // ~19 MiB — ориентир OWASP Password Storage Cheat Sheet (Argon2id)
'time_cost' => 2,
'threads' => 1,
];
if (password_needs_rehash($stored, PASSWORD_ARGON2ID, $rehashOpts)) {
$new = password_hash($password, PASSWORD_ARGON2ID, $rehashOpts);
save_password_hash($userId, $new); // поднимаем пароль на свежий хеш
}
start_session($userId);
}Точные пороги memory_cost/time_cost сверяйте с актуальной OWASP Password Storage Cheat Sheet на момент деплоя — OWASP пересматривает минимумы по мере удешевления GPU. Приведённые 19456 KiB / t=2 / p=1 — ориентир для Argon2id из этой шпаргалки; это нижняя граница, не потолок: выше по памяти/итерациям — дольше перебор.
4. Ошибки на входе и восстановлении
- User enumeration: одинаковые сообщения и статус для «нет такого пользователя» и «неверный пароль». Иначе сканер собирает список существующих логинов (для WordPress это отдельная боль —
wp-loginи?author=N). - Rate limit на попытку входа (per-IP + per-account), иначе slow brute force не ловится.
- Логирование: пишите события входа, но никогда не пишите пароль/хеш в логи.
- Reset-токен для смены пароля — одноразовый, короткоживущий (
random_bytes), привязан к юзеру, удаляется после использования; не «hash(email+time)».
Часть II. JWT: как не сломать подпись
5. Что такое JWT и чем он «не является»
JWT — это подписанная, но не зашифрованная структура header.payload.signature в base64url. Payload читается кем угодно (base64 декодируется в одну строку). Секреты/PII в payload класть нельзя. JWT не равен сессии: сервер по умолчанию ничего не хранит и не может «отозвать» выданный токен, пока не истёк срок.
6. Атаки, которые реально находят
6.1 alg: none
В заголовке разрешают алгоритм none — подпись не проверяется вовсе:
{"alg":"none","typ":"JWT"} // подпись пустая
{"sub":"admin","role":"admin"}
Серьёзные библиотеки это уже не принимают, но самописная проверка на PHP — классический источник дыры. Лечится: жёсткий allowlist алгоритмов на сервере, а не «разбираем alg из заголовка и по нему решаем».
6.2 Подмена алгоритма (RS256 → HS256)
Если публичный ключ сервера известен (/jwks), атакующий делает заголовок HS256 и подписывает HMAC-подпись самим публичным ключом как секретом. Библиотека, которая «доверяет alg из токена», проверит HMAC этим же публичным значением и пропустит.
Лечится: сервер жёстко фиксирует ожидаемый алгоритм (RS256 — и только RS256), никогда не берёт alg из заголовка как директиву.
6.3 Прочее
kid(Key ID) без фильтра → SQLi/путь к файлу, если библиотека подставляетkidв запрос за ключом.- Проверять
exp/nbf/iss/aud, а не только подпись. Токен от другогоaudienceне должен приниматься. iatдля refresh-логики.
7. Минимально правильная проверка на PHP
<?php
function jwt_verify(string $jwt, string $publicKeyPem): array
{
$parts = explode('.', $jwt);
if (count($parts) !== 3) {
throw new JwtException('malformed');
}
[$h64, $p64, $s64] = $parts;
$header = json_decode(b64url_decode($h64), true, 512, JSON_THROW_ON_ERROR);
// 1) Алгоритм — ТОЛЬКО ожидаемый. Игнорируем всё, что заявил токен.
if (($header['alg'] ?? '') !== 'RS256') {
throw new JwtException('alg not allowed');
}
// 2) Проверяем подпись RSASSA-PKCS1-v1_5 / SHA-256 по публичному ключу.
$signingInput = $h64 . '.' . $p64;
$sig = b64url_decode($s64);
$ok = openssl_verify($signingInput, $sig, $publicKeyPem, OPENSSL_ALGO_SHA256);
if ($ok !== 1) {
throw new JwtException('bad signature');
}
$claims = json_decode(b64url_decode($p64), true, 512, JSON_THROW_ON_ERROR);
// 3) Временные/структурные клеймы.
$now = time();
if (($claims['exp'] ?? 0) <= $now) throw new JwtException('expired');
if (isset($claims['nbf']) && $claims['nbf'] > $now) throw new JwtException('not yet');
if (($claims['iss'] ?? '') !== 'https://auth.example.com') throw new JwtException('bad iss');
if (($claims['aud'] ?? '') !== 'example-api') throw new JwtException('bad aud');
return $claims;
}
function b64url_decode(string $s): string
{
return base64_decode(strtr($s, '-_', '+/') . str_repeat('=', (4 - strlen($s) % 4) % 4), true)
?: throw new JwtException('bad base64url');
}Практический совет: не пишите свой verify в проде — используйте проверенную библиотеку (web-token/jwt-framework, firebase/php-jwt), и зафиксируйте допустимые алгоритмы в её конфиге (array('RS256') вместо «принять любой из header»). Сниппет выше — чтобы видеть, что именно должно проверяться.
8. Отзыв, TTL и refresh
Поскольку JWT не хранится сервером, «разлогинить/запретить» его штатно нельзя. Варианты:
- Короткий TTL access-токена (5–15 мин) + refresh-токен отдельно. Отзыв = не продлеваем refresh.
jti+ блок-лист (Redis/Кука-отзыв): на каждый запрос сверяемjtiс множеством отозванных; держим блок-лист до истеченияexp. Это reintroduces state, но делает JWT отзываемым.- Смена секрета подписи инвалидирует все токены (тяжёлый способ, для экстренных случаев).
Refresh-токен держите дольше, но: ротация refresh на каждое продление (reuse detection), привязка к устройству/пользователю, хранение только хеша refresh.
9. Где хранить на клиенте
localStorage— читается любым XSS (см. предыдущую статью). Хранить JWT там = отдать токен XSS’у.- Лучше: HttpOnly-кука для access/refresh + защита CSRF (т.к. кука уходит автоматически), либо BFF-паттерн (токены живут на вашем бэке, браузер получает только сессионную куку).
- Если JWT нужен именно в заголовке
Authorization(SPA без бэкенда-прокси) — минимизируйте TTL и жёстко закрывайте XSS.
Часть III. OAuth 2.0 + OIDC
10. Карта ролей и потоков
- Resource Owner — пользователь. Client — ваше приложение. Authorization Server — выдаёт код/токены. Resource Server — API, принимающий access-токен.
- Authorization Code flow — основной и рекомендуемый: пользователь идёт на
authorize, получаетcodeнаredirect_uri, потомcodeобменивается на токены на сервере. - ⛔ Implicit flow (токен прямо в URL-фрагменте) — deprecated OAuth 2.1: токен светится в истории браузера, не требует PKCE корректно. Не используйте.
- Client Credentials — для server-to-server (машина ↔ машина), без участия пользователя. Секрет клиента тут обязателен.
- Authorization Code + PKCE — для PWA/SPA и мобильных, где секрет клиента нельзя спрятать.
11. PKCE: почему «даже для confidential»
Authorization Code перехватывается, если злоумышленник видит code на redirect_uri. PKCE (RFC 7636) привязывает обмен code → token к секрету, известному только реальному клиенту:
client делает:
code_verifier = random(64+ байт, base64url) // остаётся у клиента
code_challenge = BASE64URL(SHA256(code_verifier)) // летит в authorize
... получаем code ...
token request: code + code_verifier // сервер проверяет SHA256(verifier)==challenge
<?php
// Генерация verifier (PKCE) на клиентской стороне/бэке
$verifier = rtrim(strtr(base64_encode(random_bytes(48)), '+/', '-_'), '=');
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
// В authorize: &code_challenge={$challenge}&code_challenge_method=S256
// Сохранить $verifier в сессии до обмена.method— толькоS256(SHA-256). Методplainуязвим, если verifier попадает в URL/логи.- RFC 7636 рекомендует verifier длиной 43–128 символов — я использую 48 байт → 64 символа, в норме.
- В OAuth 2.1 PKCE требуется для публичных клиентов и настоятельно для всех.
12. CSRF и redirect_uri: два столба
state— случайное значение из сессии клиента, возвращается наredirect_uriи сверяется на входе. Безstateзлоумышленник подсовывает свою ссылку обратного вызова и «прицепляет» ваш аккаунт к своему (login CSRF / account linking).redirect_uri— точное сравнение с зарегистрированным значением. Разрешать поддомены/«начинается с» / префиксные совпадения = открытая дверь к утечкеcode. Проверкаstr_starts_withили wildcard — классическая дыра.
<?php
// На возврате: сверяем state ДО обмена code.
$sess = $_SESSION['oauth_state'] ?? '';
if (!isset($_GET['state']) || !is_string($_GET['state'])
|| $sess === '' || !hash_equals($sess, $_GET['state'])) {
abort('state mismatch');
}
unset($_SESSION['oauth_state']); // одноразово13. Хранение токенов и scope
- Не запрашивайте лишние scope (
openid profile emailвместоemail) — principle of least scope. - Access-токен от чужого провайдера не отдавайте фронтенду «как есть», если можно держать его на бэке.
- Логаут: для OIDC используйте RP-initiated logout + отзыв refresh на своей стороне; чистая кука-сессия не отзывает токен у провайдера.
14. OIDC поверх OAuth2
OAuth2 сам по себе — про авторизацию (доступ к ресурсам), про аутентификацию — надстройка OIDC: слой id_token (JWT с sub, iss, aud, auth_time, nonce) поверх Authorization Code. Проверки id_token: подпись (allowlist alg), iss, aud (свой client_id), exp, nonce (если использовался) — ровно как в разделе JWT. Не путайте «получил access_token» с «проаутентифицировал пользователя» — для этого нужен валидный id_token.
Часть IV. Двухфакторная аутентификация (2FA/MFA)
15. Три типа факторов и почему SMS — «так себе»
- Знание (пароль). Владение (ключ FIDO2, телефон с TOTP-приложением). Неотъемлемость (биометрия).
- SMS-код формально «владение номером», но номер угоняют через SIM-swap (соци-инженерия оператора) и SS7-атаки. NIST и площадки безопасности не считают SMS сильным фактором. Годится как «лучше, чем ничего», но не для высоких рисков.
Порядок устойчивости: WebAuthn/FIDO2 (устойчив к фишингу по дизайну) > TOTP (устойчив к перебору, но фишинг-сайты перехватывают код) > SMS/Push (слабые).
16. WebAuthn/FIDO2 — предпочтительно
Ключ живит приватный ключ на устройстве, сервер хранит только публичный. Аутентификация привязана к origin (rpId + origin) — токен с фишингового сайта не проходит. В PHP проверка — через библиотеку (webauthn-php, 24wps.net/Attestation-обвязки); в WordPress — существующие плагины MFA с поддержкой WebAuthn.
Плюсы против TOTP: нет секретной «седины» на сервере, нет перехвата по фишингу, нет переноса shared secret.
17. TOTP: что проверять, если делаете сами
TOTP (RFC 6238) = HMAC-SHA1 от текущего окна времени по общему секрету, 6 цифр.
<?php
// Секрет генерируется ОДИН раз при подключении 2FA, хранится защищённо
// (в идеале — в encrypted store, не в открытом usermeta).
$secret = Base32::encode(random_bytes(20));
// Проверка: окно ±1 (допускаем дрейф часов), счётчик попыток, одноразовость.
function totp_verify(string $secret, string $code, int $window = 1): bool {
$now = intdiv(time(), 30);
for ($i = -$window; $i <= $window; $i++) {
if (hash_equals(totp_at($secret, $now + $i), $code)) {
return true;
}
}
return false;
}Что обязательно вокруг TOTP:
- Rate limit на сам ввод кода (3–5 попыток), иначе 6-значный код перебираем.
- Одноразовость: уже использованный код в текущем окне не принимается повторно (храните last-used step).
- Защита от фишинга: TOTP.pass-код пользователь отдаёт любому сайту, который его попросит — поэтому TOTP + честная сессия лучше, но WebAuthn закрывает именно фишинг.
18. Backup-коды и «выключить 2FA»
- Backup-коды генерируются как случайные строки, хранятся хешем (
password_hash), каждый одноразовый (пометить использованный). - Самая частая дыра — путь «я потерял 2FA, сбросьте его» через почту/поддержку: атакующий с угнанным имейлом отключает второй фактор. Требуйте период ожидания, подтверждение через другой канал, ручной аудит для чувствительных аккаунтов.
- 2FA включается в несколько шагов: отсканировал → ввёл код с устройства (подтверждение владения) → только после этого считать 2FA активной. Иначе «включил», сохранив секрет, но без проверки — иллюзия защиты.
Часть V. Специфика WordPress
19. Как WP хранит и проверяет пароль
WordPress не использует голый password_hash напрямую для пользователей: хеш формируется через phpass/bcrypt внутри wp_hash_password(), а проверяется через wp_check_password() (последняя умеет распознавать и старые форматы phpass, и современные bcrypt).
<?php
// Никогда не сравнивайте хеши сами — только wp_check_password.
$plain = $inputPassword;
$stored = get_user_meta($userId, '...') ?: $user->user_pass; // на деле берём из БД
if (wp_check_password($plain, $stored, $userId)) {
// успех
wp_set_auth_cookie($userId, $remember, is_ssl());
}Ключевые правила:
wp_hash_password()никогда не вызывать для проверки — это односторонняя функция хеширования; для сверки существуетwp_check_password($password, $hash, $user_id). Третий аргумент участвует в миграции старых хешей.- Смена пароля меняет хеш и сбрасывает сессионные токены (
WP_Session_Tokens) — существующие «запомненные» куки на других устройствах инвалидируются штатно. При ручных сбросах через прямой апдейтuser_pass(минуяwp_update_user) — вызовитеWP_Session_Tokens::get_instance($user_id)->destroy_all()сами. remember-meкука живёт 14 дней — для sensitive-аккаунтов отключайте remember.
20. Application Passwords и JWT в WP
- Application Passwords (ядро с 5.6) — правильный способ дать пользователю «секрет» для REST/XML-RPC без его основного пароля. Отображается один раз, хранится хешем, отзывается в профиле.
- JWT-плагины (
jwt-authentication-for-wp-rest-apiи т.п.) опасны тем, что часто: ставят длинный TTL, не дают отзыва, а ключ подписи лежит вwp-config/опции. Если используете — фиксируйтеalg, короткий TTL, держите секрет вне репозитория, и помните: JWT-плагины в WP не дают «stateless logout». - REST по cookie требует
X-WP-Nonce(см. статью про CSRF) — JWT-подход его обходит, поэтому жёстко нуженalg-allowlist и срок.
21. Двухфакторка и вход в админку
- Для 2FA в WordPress — плагины класса WP 2FA / miniOrange / Duo, где есть WebAuthn/TOTP и принудительная политика по ролям; самописная «TOTP в functions.php» часто ломает recovery.
- Усильте сам вход: rate-limit логина (иначе brute по
wp-login.php), отключите/закройтеxmlrpc.php(источник brute-force amplify),?author=1закройте (enumeration), 404/403 для несуществующих логинов одинаковые.
Чек-лист
Хеширование:
password_hash(Argon2id → фолбэк bcrypt), проверкаpassword_verify; никогда==/md5/sha1/«солёный SHA».- Поле
VARCHAR(255); про 72-байтовый предел bcrypt помните; длинные пароли — Argon2id. password_needs_rehashпри входе для миграции; параметры сверять с OWASP Cheat Sheet на момент деплоя.- Одинаковые ответы/тайминги для «нет юзера» vs «неверный пароль», rate-limit, секреты не в логах.
JWT:
- Алгоритм фиксируется на сервере (allowlist),
algиз токена — директивой не является;noneиRS256→HS256отсекаются. - Проверяются
exp/nbf/iss/aud+ подпись (hash_equalsдля HMAC;openssl_verifyдля RSA). - Короткий TTL + refresh; отзыв через
jti-блок-лист или смену секрета. Секретов/PII в payload нет. Хранение — HttpOnly-кука/BFF, неlocalStorage.
OAuth2 / OIDC:
- Authorization Code (+ PKCE, только S256), никаких Implicit.
stateсверяется черезhash_equalsдо обменаcode;redirect_uri— точное совпадение.- Секрет клиента не в браузере; scope по минимуму; аутентификация = валидный
id_token(OIDC), а не факт наличия access-токена.
2FA:
- Приоритет WebAuthn/FIDO2 > TOTP > SMS (SMS не считать сильной).
- Rate-limit на код, одноразовость, окно ±1 для TOTP.
- Backup-коды хешем, одноразовые; путь «сбросить 2FA» закрыт от того, у кого только почта.
- Включение 2FA подтверждается вводом кода до того, как считать её активной.
WordPress:
- Проверка пароля только через
wp_check_password; смена пароля/destroy_all()инвалидируют сессии. - Application Passwords для API-секретов; с JWT-плагинами — фиксированный
alg, короткий TTL, секрет вне репо. - Rate-limit входа, закрыты
xmlrpc/?author=, одинаковые ответы на unknown user.
Источники
- OWASP — Authentication Cheat Sheet, Password Storage Cheat Sheet, Session Management Cheat Sheet
- RFC 7636 — Proof Key for Code Exchange (PKCE)
- IETF OAuth 2.1 (Authorization Code + PKCE, запрет Implicit) — draft-ietf-oauth-v2-1
- RFC 6238 — TOTP; RFC 7518/7519 — JWS/JWT; RFC 7517/8725 — ключи и JWT BCP
- W3C — WebAuthn
- PHP — password_hash, password_verify, password_needs_rehash, openssl_verify
- WordPress — wp_check_password, wp_hash_password, Application Passwords
👤 Зарегистрируйтесь, чтобы добавить материал в закладки.
