Зачем паролю нужен специальный хэш
Пароль в базе нельзя хранить ни в открытом виде, ни «зашифрованным обратно». Для проверки логина приложению не нужно получать исходный пароль. Ему нужно ответить на один вопрос: совпадает ли введённая строка с тем секретом, который пользователь когда-то задал.
Для этого используют односторонний password hashing. При регистрации PHP создаёт медленный хэш и кладёт его в таблицу пользователей. При логине PHP проверяет введённый пароль против сохранённого хэша. Если база утечёт, атакующий не увидит пароли напрямую, а будет вынужден угадывать их офлайн: брать кандидат, считать хэш, сравнивать. Поэтому для паролей нужны не быстрые md5() или hash('sha256', ...), а специально медленные алгоритмы.
<?php
// Регистрация
$password = $_POST['password'];
$hash = password_hash($password, PASSWORD_DEFAULT);
$stmt = $pdo->prepare(
'INSERT INTO users (email, password_hash) VALUES (:email, :password_hash)'
);
$stmt->execute([
'email' => $_POST['email'],
'password_hash' => $hash,
]);SQL-часть здесь всё ещё должна идти через placeholders, как в Prepared statements и SQL injection. password_hash() решает другую задачу: защищает секреты после возможной утечки базы.
flowchart TD
A[Регистрация: пользователь вводит пароль] --> B[password_hash]
B --> C[(users.password_hash)]
D[Логин: пользователь вводит пароль] --> E[SELECT password_hash по email]
E --> F[password_verify]
C --> F
F -->|true| G[Создать сессию]
F -->|false| H[Нейтральный отказ]password_hash()
password_hash() создаёт строку, в которой уже есть всё нужное для будущей проверки: алгоритм, параметры стоимости и соль. Поэтому в базе обычно хранят одно поле вроде password_hash VARCHAR(255), а не отдельные колонки salt, algorithm, cost.
$2y$12$abcdefghijklmnopqrstuuuuuuuuuuuuuuuuuuuuuuuuuuuuu
│ │ └─ соль и результат хэширования
│ └──── cost
└──────── алгоритм bcrypt<?php
$hash = password_hash('correct horse battery staple', PASSWORD_DEFAULT);
echo $hash;
// Например: $2y$12$...дальше bcrypt-хэш...PASSWORD_DEFAULT удобен для обычного приложения: PHP выбирает текущий дефолтный алгоритм, а проект не пришивает себя к конкретной строке. Но у этого есть практическое следствие: длина результата может измениться в будущих версиях PHP. Не делайте колонку ровно под сегодняшний bcrypt-хэш; VARCHAR(255) — нормальный безопасный запас.
Если вы сознательно выбираете алгоритм, в современном PHP часто смотрят на PASSWORD_ARGON2ID, если сборка PHP поддерживает Argon2. У него есть параметры памяти, времени и параллелизма. Для легаси и максимально переносимого кода часто остаются на PASSWORD_BCRYPT. У bcrypt есть важное ограничение: пароль обрабатывается максимум до 72 байт, что особенно заметно на длинных UTF-8 строках.
<?php
$hash = password_hash('secret', PASSWORD_ARGON2ID, [
'memory_cost' => PASSWORD_ARGON2_DEFAULT_MEMORY_COST,
'time_cost' => PASSWORD_ARGON2_DEFAULT_TIME_COST,
'threads' => PASSWORD_ARGON2_DEFAULT_THREADS,
]);Не надо писать свой «улучшенный» хэш из sha1, md5, uniqid, rand, нескольких циклов и ручной соли. Такие конструкции обычно выглядят надёжно только потому, что их сложно читать. Криптография не становится безопаснее от самодельности.
Соль не надо хранить отдельно
Соль — это случайная добавка, из-за которой два одинаковых пароля получают разные хэши. Она мешает массовому сравнению и заранее посчитанным таблицам. Но в PHP её не нужно генерировать руками: password_hash() сам создаёт криптографически стойкую соль и включает её в итоговую строку.
password_hash('secret', PASSWORD_DEFAULT) → $2y$12$A...
password_hash('secret', PASSWORD_DEFAULT) → $2y$12$Q...
Один и тот же пароль, но разные хэши из-за новой случайной соли.Плохой признак в ревью:
<?php
$salt = md5(time() . rand());
$hash = sha1($salt . $_POST['password']);Хороший вариант короче и сильнее:
<?php
$hash = password_hash($_POST['password'], PASSWORD_DEFAULT);Если в старом проекте уже есть отдельная колонка salt, не надо механически переносить этот паттерн в новый код. Лучше спланировать миграцию: при успешном логине проверить старый формат, затем пересохранить пароль через password_hash().
password_verify() и timing attacks
Для проверки пароля нельзя сравнивать строки самому. У хэша есть формат, алгоритм и параметры, а ещё есть риск timing attack: когда различия во времени ответа помогают угадывать секрет по частям. password_verify() знает формат хэша от password_hash() и безопасен для такой проверки.
<?php
function login(PDO $pdo, string $email, string $password): bool
{
$stmt = $pdo->prepare(
'SELECT id, password_hash FROM users WHERE email = :email'
);
$stmt->execute(['email' => $email]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$user) {
return false;
}
if (!password_verify($password, $user['password_hash'])) {
return false;
}
// Здесь уже можно создать сессию.
return true;
}После успешной проверки обычно создают сессию, обновляют session id и выставляют cookie с нормальными флагами. Это уже граница статьи Куки и сессии, но важно помнить связь: password hashing защищает сохранённый пароль, а сессия защищает уже аутентифицированное состояние.
password_needs_rehash()
Параметры хэширования не вечны. Железо ускоряется, PHP меняет дефолты, проект может перейти с bcrypt на Argon2id или поднять cost. Нельзя просто «перехэшировать всю базу», потому что у вас нет исходных паролей. Зато при следующем успешном логине пароль есть в памяти на один момент, и тогда можно обновить хэш.
<?php
$algo = PASSWORD_DEFAULT;
$options = [];
if (password_verify($password, $user['password_hash'])) {
if (password_needs_rehash($user['password_hash'], $algo, $options)) {
$newHash = password_hash($password, $algo, $options);
$stmt = $pdo->prepare(
'UPDATE users SET password_hash = :hash WHERE id = :id'
);
$stmt->execute([
'hash' => $newHash,
'id' => $user['id'],
]);
}
return true;
}Это спокойная миграция без принудительного сброса пароля для всех. Для критичных систем иногда всё равно требуют reset старых паролей, но базовый механизм обновления — именно такой.
random_bytes() для токенов, а не для паролей пользователей
random_bytes() генерирует криптографически стойкие случайные байты. Его используют для reset-токенов, email verification, API secrets, CSRF-токенов и других значений, которые приложение создаёт само.
<?php
$token = bin2hex(random_bytes(32)); // 64 hex-символа
$stmt = $pdo->prepare(
'INSERT INTO password_resets (user_id, token_hash, expires_at)
VALUES (:user_id, :token_hash, :expires_at)'
);
$stmt->execute([
'user_id' => $userId,
'token_hash' => hash('sha256', $token),
'expires_at' => $expiresAt,
]);
// Пользователю отправляют $token, а в базе хранят только token_hash.Не путайте это с паролями. Пароль выбирает человек, поэтому его хэшируют медленным password hashing алгоритмом. Reset-токен создаёт сервер, он уже имеет высокую энтропию, поэтому его обычно хранят как быстрый хэш токена и ограничивают сроком жизни. CSRF-токены подробнее относятся к CSRF и state-changing запросы.
Пароль пользователя: человек выбрал → password_hash() → медленная проверка
Reset-токен: сервер сгенерировал → hash('sha256') → срок жизни и одноразовое использованиеЧто показывать пользователю, а что логировать
При неудачном логине не стоит различать сообщения «такого email нет» и «пароль неверный». Это помогает перебору аккаунтов. Обычно показывают одно нейтральное сообщение: «Неверный email или пароль».
Ошибки базы, исключения от random source и проблемы конфигурации не должны попадать в HTML-ответ. Как и в статье Транзакции и режимы ошибок PDO, подробности уходят в логи, а пользователю показывается безопасный текст. Production-настройки display_errors, логирования и поверхности атаки связаны с Конфигурация безопасности PHP.
Чего не стоит делать самому
Не храните plaintext-пароли. Не шифруйте пароли «чтобы потом расшифровать». Не используйте md5, sha1 или обычный hash() для пользовательских паролей. Не генерируйте соль через time(), rand(), mt_rand() или uniqid(). Не сравнивайте пароль и хэш через == или ===. Не придумывайте собственный формат хэша, если нет очень сильной причины и криптографической экспертизы.
Нормальный PHP-код для паролей выглядит скучно: password_hash(), password_verify(), password_needs_rehash(), random_bytes() для серверных токенов, аккуратная работа с БД через PDO и нейтральные сообщения об ошибках. Именно эта скука здесь и нужна.
См. также
- Куки и сессии — что происходит после успешного логина и как хранится аутентифицированное состояние.
- Prepared statements и SQL injection — как безопасно сохранять и читать
password_hashиз базы. - CSRF и state-changing запросы — где нужны случайные токены для защиты действий пользователя.
- Конфигурация безопасности PHP — production-настройки ошибок, сессий и опасных опций.
- Ошибки, исключения и Throwable — как обрабатывать сбои без утечки деталей пользователю.