Храните пароли через password_hash($password, PASSWORD_DEFAULT) и проверяйте вход через password_verify($password, $hash), которая возвращает true или false. Никогда не храните сам пароль и никогда не используйте для него md5() или sha1().
Запустите дважды: хеш каждый раз другой, и обе версии проходят проверку. То же относится к каждому хешу на этой странице, поэтому ваш вывод не совпадёт ни с чьим другим.
Вход через password_verify
Вход проверяет отправленный пароль по хешу, сохранённому при создании аккаунта. Этот блок "регистрирует" одного пользователя, а потом показывает форму входа; попробуйте правильный пароль (hunter2), потом неправильный.
Сообщение об ошибке одинаково для неизвестного email и неверного пароля. Если сказать, что именно не так, атакующий узнает, у каких email есть аккаунты. После успешного входа настоящий сайт сохраняет ID пользователя в сессии, как показано на странице о сессиях.
Что внутри хеша
Хеш bcrypt это одна строка, в которой есть всё, что нужно password_verify(): алгоритм, cost и соль, а за ними сам хеш. password_get_info() читает первые части обратно.
Поскольку соль хранится в хеше, нужен только один столбец в базе данных, а два пользователя с одинаковым паролем всё равно получают разные хеши. Делайте этот столбец VARCHAR(255): bcrypt занимает 60 символов, но PASSWORD_DEFAULT может смениться на алгоритм с более длинным результатом.
Выбор cost
Cost определяет, насколько намеренно медленно вычисляется каждый хеш, и каждый шаг вверх удваивает работу. PHP 8.3 использует для bcrypt cost 10 (PHP 8.4 поднял значение по умолчанию до 12). Вход должен тратить на хеширование примерно от 100 до 300 мс; замерьте на своём сервере:
Числа зависят от машины, но каждая строка должна быть примерно вдвое больше предыдущей. Больше значит надёжнее против взлома и медленнее для каждого входа, поэтому выбирайте самый высокий cost, который может себе позволить ваша страница входа.
Обновление старых хешей через password_needs_rehash
Когда вы повышаете cost или PHP меняет PASSWORD_DEFAULT, существующие хеши продолжают работать, а password_needs_rehash() сообщает, какие устарели. Пароль в открытом виде у вас есть только в момент входа, поэтому обновляют именно там:
Префикс $2y$10$ становится $2y$12$, и при следующем входе password_needs_rehash() возвращает false.
Никогда не используйте md5 или sha1 для паролей
md5() и sha1() это быстрые хеши общего назначения без соли. Быстрота как раз плоха для паролей: атакующий с утёкшей таблицей может перебирать миллиарды вариантов в секунду на GPU, а хеш без соли от распространённого пароля уже есть в публичных таблицах поиска. Сравните скорость:
За время, пока password_hash() делает один хеш, md5 делает сотни тысяч, поэтому каждая попытка взломщика во столько же раз дешевле. Поищите 5f4dcc3b5aa765d61d8327deb882cf99, и вы сразу найдёте слово "password".
Если вам досталась таблица хешей md5, преобразовать их напрямую нельзя. Вместо этого оберните их: храните password_hash($oldMd5) и проверяйте через password_verify(md5($input), $stored), а затем заменяйте каждый обычным хешем при следующем входе этого пользователя.
Argon2id и лимит в 72 байта
PASSWORD_ARGON2ID тратит больше памяти на хеш, и это делает взлом на GPU гораздо дороже. Он доступен, когда PHP собран с libargon2 (так собрано большинство пакетов Linux); проверьте через defined('PASSWORD_ARGON2ID').
Последняя строка выводит bool(true): два разных пароля совпадают, потому что bcrypt игнорирует всё после 72-го байта. Реальные пароли редко до этого доходят, но парольные фразы на японском или других многобайтовых письменностях достигают 72 байтов примерно на 24 символах. Если вы разрешаете очень длинные пароли, используйте Argon2id, у которого такого лимита нет. password_verify() работает с обоими алгоритмами, поэтому переключиться можно через password_needs_rehash(), как показано выше.
Часто задаваемые вопросы
Почему password_hash каждый раз даёт разный результат?
Каждый вызов генерирует новую случайную соль и сохраняет её внутри хеша, поэтому один и тот же пароль каждый раз даёт другую строку. Так и задумано: password_verify() достаёт соль из сохранённого хеша, поэтому для правильного пароля всё равно возвращает true.
Какой длины должен быть столбец для пароля под password_hash?
Хеш bcrypt от PASSWORD_DEFAULT сегодня занимает 60 символов, но алгоритм по умолчанию может измениться в будущей версии PHP. Используйте VARCHAR(255), чтобы более длинный хеш поместился без миграции.
Почему password_verify всегда возвращает false?
Обычные причины: обрезанный хеш (столбец VARCHAR(50) обрезает хеш из 60 символов), передача вторым аргументом свежего password_hash() от чего-то вместо сохранённого хеша, сравнение с хешем, сделанным через md5(), или лишние пробелы в сохранённом значении. Проверьте, что strlen($hash) равно 60, и передавайте первым аргументом пароль в открытом виде.
Можно ли расшифровать значение password_hash в PHP?
Нет. Хеш односторонний: функции, которая превратила бы его обратно в пароль, нет. Чтобы проверить пароль, хешируют попытку и сравнивают, это и делает password_verify(). Для забытого пароля отправляйте ссылку для сброса, а не старый пароль.
Что использовать: PASSWORD_DEFAULT, PASSWORD_BCRYPT или PASSWORD_ARGON2ID?
PASSWORD_DEFAULT обычный выбор: сегодня это bcrypt, и позже PHP может перевести его на более стойкий алгоритм, а password_needs_rehash() позволяет переходить на него постепенно. PASSWORD_ARGON2ID стойче к взлому на GPU, и его вполне можно использовать, если ваша сборка PHP его включает.