<? phpukraine СПІВБЕСІДИ
⌕Пошук по платформі
БЕЗПЕКА · MIDDLE

Як безпечно зробити скидання пароля і навіщо порівнювати токени через `hash_equals()`?

Токен скидання генерують криптостійким генератором (`random_bytes()`, `Str::random()`), у базі зберігають лише його хеш, дають короткий термін дії і видаляють після використання. Порівнюють секрети через `hash_equals()`, бо `===` зупиняється на першому відмінному байті. Відповідь форми однакова для відомого і невідомого email, а час відповіді вирівнюють, щоб не можна було перевірити, хто зареєстрований.

Що не так з перевіркою `if ($request->token === $user->reset_token)`?
На формі «Забули пароль?» для відомого email пишемо «Лист надіслано», для невідомого «Користувача не знайдено». У чому проблема?
Чим згенерувати токен для посилання скидання пароля? `md5(uniqid())` підійде?
Як Laravel зберігає токени скидання і чому не відкритим текстом?
password reset hash_equals timing attack random_bytes user enumeration Timebox

Посилання «скинути пароль» дає повний доступ до акаунта, поки воно дійсне, тому токен у ньому треба розглядати як тимчасовий пароль. Звідси перша вимога: непередбачуваність. uniqid() будується з поточного часу в мікросекундах, rand() і mt_rand() не криптостійкі, а обгортка з md5() нічого до випадковості не додає. Криптостійкі джерела в PHP це random_bytes() і random_int(); Str::random() у Laravel теж бере байти з random_bytes(). 32 байти, перетворені в hex, дають токен, який не перебрати.

Друга вимога стосується зберігання. Відкритий токен у базі означає, що будь-хто з доступом на читання, через бекап, репліку чи SQL-інʼєкцію, скине пароль кожному, хто нещодавно натиснув «забув пароль». Тому в базі лежить хеш. Оскільки токен довгий і випадковий, тут досить швидкого sha256, і за ним зручно шукати; Laravel іде далі і хешує токен через Hash::make(), а запис шукає за email. До цього додають термін дії (у Laravel 60 хвилин за замовчуванням), видалення після використання і обмеження частоти запитів на лист.

Порівняння секретів окрема тема. === для рядків іде байт за байтом і повертається на першій розбіжності, тож час відповіді залежить від того, скільки символів збіглось. З достатньою кількістю вимірів це дає змогу підбирати секрет посимвольно. hash_equals($known, $input) проходить рядок повністю, і час від вмісту не залежить. Його ставлять скрізь, де порівнюють токени, HMAC-підписи вебхуків і API-ключі. password_verify() порівнює безпечно сам, обгортати його не треба.

Останнє, що відрізняє сильну відповідь, це user enumeration. Текст «користувача з таким email не знайдено» перетворює форму на сервіс перевірки, чи зареєстрована людина, а для сайту вакансій чи клініки сам факт реєстрації вже чутливий. Але однакового тексту мало: якщо для існуючого користувача сервер рахує bcrypt чи надсилає лист, а для неіснуючого одразу відповідає, різницю видно по часу. Laravel загортає вхід і скидання пароля в Timebox на 200 мс, а однакове повідомлення застосунок має показати сам, бо брокер повертає окремий статус для невідомого email. І після успішного скидання інші сесії та remember-токени інвалідовують, а на пошту йде лист «ваш пароль змінено».

// Було: передбачуваний токен, відкритий текст у базі, порівняння через ===.
$token = md5(uniqid());                       // uniqid() це поточний час
$user->reset_token = $token;
if ($request->input('token') === $user->reset_token) { /* ... */ }

// Стало: 32 випадкові байти, у базі лише хеш, порівняння за сталий час.
$token = bin2hex(random_bytes(32));           // 64 hex-символи в лист
DB::table('reset_tokens')->updateOrInsert(
    ['user_id' => $user->id],
    ['token_hash' => hash('sha256', $token), 'expires_at' => now()->addMinutes(30)],
);

// Перевірка: термін, хеш, одноразовість.
$row = DB::table('reset_tokens')->where('user_id', $user->id)->first();
$valid = $row !== null
    && now()->lt($row->expires_at)
    && hash_equals($row->token_hash, hash('sha256', (string) $request->input('token')));

if ($valid) {
    DB::table('reset_tokens')->where('user_id', $user->id)->delete();
    // далі: новий пароль через Hash::make(), новий remember_token, лист-повідомлення
}

// На практиці в Laravel усе це вже робить брокер:
$status = Password::sendResetLink($request->only('email'));
// Однакова відповідь незалежно від того, чи знайшовся email.
return back()->with('status', 'Якщо акаунт існує, ми надіслали лист із посиланням.');
Що токен скидання еквівалентний паролю на час своєї дії, тому до нього ті самі вимоги: непередбачуваність, хеш у базі, короткий термін і одноразовість.
Що `uniqid()`, `rand()`, `mt_rand()` і `md5(time())` передбачувані, а криптостійкі джерела в PHP це `random_bytes()` і `random_int()`.
Що `hash_equals()` порівнює за час, що не залежить від позиції першої розбіжності, і що `password_verify()` вже робить це сам.
Що user enumeration трапляється і через текст відповіді, і через час: хешування пароля для існуючого користувача займає помітні сотні мілісекунд.
Що після скидання пароля інші сесії й remember-токени треба інвалідувати, а користувача повідомити листом.
Генерувати токен через `md5(uniqid())` або `sha1(time())`: хеш не додає випадковості, а `uniqid()` будується з поточного часу.
Зберігати токен у базі відкритим текстом: витік бекапу чи read-only SQL-інʼєкція дає змогу скинути пароль будь-кому з активним запитом.
Показувати «Користувача з таким email не знайдено»: форма стає безкоштовним сервісом перевірки, чи є людина у вас, а для сайту з вакансіями чи медициною це вже витік.
Порівнювати токен через `==`: крім таймінгу, рядки на зразок `"0e123"` і `"0e456"` дорівнюють один одному і в PHP 8.4, бо обидва числові й порівнюються як числа.
Робити токен без терміну дії або дозволяти використати його двічі: старий лист у скриньці стає постійним ключем до акаунта.
ПОРАДА

Скажіть фразу «токен скидання - це тимчасовий пароль» і виведіть з неї всі вимоги: випадковий, хешований у базі, короткоживучий, одноразовий. А потім додайте те, про що рідко згадують: однакову відповідь і однаковий час для існуючого і неіснуючого email.

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

`hash_equals()` проходить рядки повністю, тож час не підказує, скільки символів збіглось. Випадковість `md5(uniqid())` обмежена випадковістю `uniqid()`, а це мікросекунди поточного часу, і довжина хешу тут нічого не додає. Відкритий токен у базі за годину встигнуть використати з бекапу чи через SQL-інʼєкцію. `password_verify()` сам порівнює хеші безпечно за часом.