<? phpukraine СПІВБЕСІДИ
Пошук по платформі
CORE PHP · JUNIOR ЧАСТО ПИТАЮТЬ

Як правильно зберігати паролі в PHP?

Хешувати треба `password_hash()` і перевіряти `password_verify()`: функція сама генерує криптостійку сіль і кладе її разом з алгоритмом і вартістю в один рядок. Окрема колонка для солі не потрібна, а параметри піднімають через `password_needs_rehash()` під час успішного логіну.

Чим ви хешуєте паролі і де зберігаєте сіль?
Чим `md5($salt . $password)` гірший за `password_hash()`, якщо сіль випадкова і довга?
У базі хеші з `cost = 10`, ми хочемо `12`. Що робити зі старими користувачами?
Ви обрали argon2id. Скільки памʼяті зʼїсть сторінка логіну, якщо в неї одночасно прийде 50 людей?
password_hash password_verify bcrypt argon2id security hashing

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()` сам бере сіль з CSPRNG, і рядок `$2y$12$...` самоописний: алгоритм, вартість і сіль читаються з нього, тому `password_verify()` не потребує жодних додаткових колонок.
Що проблема md5/sha1 не в солі, а у швидкості: це хеші загального призначення, GPU рахує їх мільярдами за секунду, тоді як bcrypt з `cost=12` дає тисячі.
Що `cost` у bcrypt - показник степеня: `12` означає `2^12` ітерацій, тобто +1 подвоює час перевірки.
Що argon2id memory-hard і його `memory_cost` (за замовчуванням 65536 КіБ = 64 МіБ) множиться на кількість одночасних логінів, тому параметри підбирають під залізо, а не копіюють з блогу.
Що `password_needs_rehash()` викликають після вдалого `password_verify()`, поки в руках є плейнтекст, і оновлюють хеш по одному користувачу без масового скидання паролів.
Тримати окрему колонку `salt` і склеювати її з паролем вручну: сіль уже всередині хеша, а власна схема ламає `password_verify()` і `password_needs_rehash()`.
Вважати, що `md5` з довгою сіллю безпечний: сіль рятує від райдужних таблиць, але не сповільнює перебір конкретного хеша.
Порівнювати хеші через `==`: крім небезпечного порівняння, рядки виду `0e1234...` PHP трактує як числа в науковій нотації, і два різні хеші виявляються «рівними».
Оголошувати колонку `VARCHAR(60)` під bcrypt: перехід на argon2id дає ~97 символів, і база мовчки обрізає хеш, після чого логін ламається для всіх.
Ставити `PASSWORD_ARGON2ID` з `memory_cost` у 256 МіБ на shared-хостингу і покласти сервер першим же напливом логінів.
Забувати про межу bcrypt у 72 байти й думати, що довга парольна фраза додає стійкості: усе після 72-го байта відкидається.
ПОРАДА

Скажіть одним реченням: «сіль генерує `password_hash()`, вона лежить усередині хеша разом з алгоритмом і вартістю, тому окремої колонки немає, а параметри піднімаються через `password_needs_rehash()` на логіні». Далі згадайте, що bcrypt ріже пароль на 72 байтах, а argon2id тягне `memory_cost` на кожен паралельний запит: ці дві деталі відрізняють людину, яка це вже запускала, від людини, яка прочитала документацію.

оновлено 8 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

Рядок `$2y$12$...` самоописний: у ньому і алгоритм, і вартість, і сіль, тому хеші з різним `cost` спокійно співіснують в одній колонці. `password_needs_rehash()` порівнює параметри старого хеша з поточними й повертає `true`, а перерахувати хеш можна лише там, де є плейнтекст, тобто одразу після вдалого `password_verify()`.