Зачем паролю нужен специальный хэш

Пароль в базе нельзя хранить ни в открытом виде, ни «зашифрованным обратно». Для проверки логина приложению не нужно получать исходный пароль. Ему нужно ответить на один вопрос: совпадает ли введённая строка с тем секретом, который пользователь когда-то задал.

Для этого используют односторонний 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[Нейтральный отказ]
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 hashing.
Quick recall
Почему для пользовательских паролей не подходят `md5()`, `sha1()` или `hash('sha256', ...)`, даже если результат хранится не в plaintext?

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, нескольких циклов и ручной соли. Такие конструкции обычно выглядят надёжно только потому, что их сложно читать. Криптография не становится безопаснее от самодельности.

Quick recall
Какой минимум данных обычно нужно хранить в базе для пароля, если он создан через `password_hash()`?

Соль не надо хранить отдельно

Соль — это случайная добавка, из-за которой два одинаковых пароля получают разные хэши. Она мешает массовому сравнению и заранее посчитанным таблицам. Но в 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().

Quick recall
Почему в новом PHP-коде не надо отдельно генерировать и хранить соль для пароля?

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 и нейтральные сообщения об ошибках. Именно эта скука здесь и нужна.

См. также

Sources

  1. PHP Manual: password_hash
  2. PHP Manual: password_verify
  3. PHP Manual: password_needs_rehash
  4. PHP Manual: random_bytes
  5. OWASP Cheat Sheet Series: Password Storage