password_hash() вирішує рівно ту частину задачі, яку розробники раніше писали руками і майже завжди писали неправильно. Функція бере сіль з криптографічно стійкого генератора (не з rand() і не з uniqid()), проганяє пароль через повільний алгоритм і повертає самоописний рядок: $2y$12$ це ідентифікатор bcrypt і вартість, далі 22 символи солі й 31 символ власне хеша. password_verify() розбирає цей рядок, дістає з нього алгоритм, cost і сіль, рахує хеш від наданого пароля й порівнює за фіксований час. Через це окрема колонка salt тільки шкодить: стійкості вона не додає, зате ламає весь механізм оновлення параметрів. З PHP 8.0 функція більше не повертає false при невідомому алгоритмі, а кидає ValueError, і константи стали рядками (PASSWORD_BCRYPT це "2y", PASSWORD_ARGON2ID це "argon2id").
Претензія до md5 і sha1 стосується не солі. Сіль потрібна: вона знецінює райдужні таблиці й ховає той факт, що двоє користувачів мають однаковий пароль. Обчислення вона при цьому не сповільнює, а md5 із sha1 проєктувалися якомога швидшими, бо це хеші для контрольних сум. Порядок величин видно з бенчмарків hashcat: одна сучасна відеокарта перебирає md5 мільярдами комбінацій за секунду, а bcrypt з cost = 12 - тисячами. Різниця в мільйони разів означає, що злита база з md5-хешами (навіть із солями) розкривається за дні, а та сама база з bcrypt лишається переважно цілою, крім найпростіших паролів. Ітерований sha256 у власній реалізації теж не рятує: алгоритм і кількість раундів доводиться зберігати самому, а помилки в такому коді знаходять уже після інциденту.
Вибір між bcrypt і argon2id диктує інфраструктура, а не «сучасність» алгоритму. bcrypt працює будь-де без додаткових залежностей, за замовчуванням має cost = 10, і з PHP 8.4 це значення підняли до 12. Його відома межа - 72 байти: усе, що довше, мовчки відкидається, тому парольна фраза на 100 символів захищена рівно як її перші 72 байти (в UTF-8 кирилиця займає два байти на літеру, тож ліміт настає ще швидше). Якщо вам потрібні довгі паролі, пароль спершу проганяють через hash('sha256', $plain, true) і кодують у base64, щоб у bcrypt не потрапили нульові байти, і лише потім хешують. argon2id (доступний з PHP 7.3 за умови збірки з libargon2 або, з PHP 7.4, з libsodium) memory-hard, тому виграш спецзаліза набагато менший. Платити за це доводиться памʼяттю: memory_cost за замовчуванням 65536 КіБ, тобто 64 МіБ на кожне окреме хешування; п'ятдесят одночасних логінів це вже 3.2 ГіБ, і рахувати це треба до вмикання, а не після OOM-killer.
Параметри стійкості старіють, тому їх доводиться піднімати, і password_needs_rehash() існує саме для цього. Функція читає алгоритм та опції з наявного хеша, порівнює з тим, що ви вважаєте актуальним, і каже, чи хеш застарів. Оновити його можна лише в одному місці програми - одразу після успішного password_verify(), поки плейнтекст ще в памʼяті. Міграція тоді проходить непомітно: підняли cost у конфізі, і база переїжджає сама, у міру того як люди заходять. Той самий прийом закриває перехід із legacy-схеми: якщо в колонці лежить md5, звіряєте його через hash_equals() і при збігу перезаписуєте поле результатом password_hash().
Решта деталей коштує дешево, а ламає боляче. Колонка має бути VARCHAR(255), бо 60 символів bcrypt це не назавжди, а обрізаний базою хеш ви помітите лише тоді, коли ніхто не зможе увійти. Пароль не має потрапляти в логи, у тіло винятку, у dd() і в тіло GET-запиту. Порівняння хешів через == лишається пасткою через '0e...'-рядки, тому для токенів і підписів беруть hash_equals(). Час відповіді на неіснуючий email треба вирівняти з часом на існуючий, інакше форма логіну перетворюється на сервіс перевірки, хто у вас зареєстрований. Ну і стійкість хеша сама по собі не замінює обмеження спроб входу, тому rate limiting на форму логіну потрібен незалежно від того, bcrypt у вас чи argon2id. У Laravel усе це вже загорнуте у фасад Hash (Hash::make(), Hash::check(), Hash::needsRehash()) з налаштуванням драйвера і раундів у config/hashing.php, і власну обгортку над password_hash() там писати немає сенсу.
declare(strict_types=1);
// Алгоритм і опції - у конфізі, а не розкидані по коду
const PASSWORD_ALGO = PASSWORD_BCRYPT;
const PASSWORD_OPTIONS = ['cost' => 12]; // +1 до cost = вдвічі довше
// Реєстрація: сіль генерується всередині, окрема колонка не потрібна
$hash = password_hash($plain, PASSWORD_ALGO, PASSWORD_OPTIONS);
// $2y$12$<22 символи солі><31 символ хеша>, разом 60 символів.
// Колонку робіть VARCHAR(255): argon2id дає ~97 і формат ще змінюватиметься.
// Логін
$user = $users->findByEmail($email);
// Фіктивний хеш, щоб час відповіді не видавав, чи є такий email у базі
$storedHash = $user?->passwordHash
?? '$2y$12$C6UzMDM.H6dfI/f/IKcEe.crVdKZjLcOAOx8hIfBW/f4jgs4LFxHi';
if (! password_verify($plain, $storedHash) || $user === null) {
throw new InvalidCredentials('Невірний email або пароль');
}
// Плейнтекст живий лише тут - єдиний момент, коли можна перехешувати
if (password_needs_rehash($storedHash, PASSWORD_ALGO, PASSWORD_OPTIONS)) {
$users->updateHash($user->id, password_hash($plain, PASSWORD_ALGO, PASSWORD_OPTIONS));
}
// argon2id: памʼять множиться на кількість одночасних логінів
$argon = password_hash($plain, PASSWORD_ARGON2ID, [
'memory_cost' => 65536, // 64 МіБ на КОЖНЕ хешування
'time_cost' => 4,
'threads' => 2,
]);
print_r(password_get_info($argon)); // algoName => argon2id, options => [...]
// Так не можна:
// $hash = md5($salt . $plain); // мільярди спроб за секунду на GPU
// if ($storedHash == $computed) // '0e12...' == '0e98...' дає true
Скажіть одним реченням: «сіль генерує `password_hash()`, вона лежить усередині хеша разом з алгоритмом і вартістю, тому окремої колонки немає, а параметри піднімаються через `password_needs_rehash()` на логіні». Далі згадайте, що bcrypt ріже пароль на 72 байтах, а argon2id тягне `memory_cost` на кожен паралельний запит: ці дві деталі відрізняють людину, яка це вже запускала, від людини, яка прочитала документацію.