---
title: "Bcrypt і 72 байти: два RFC для PHP 8.7 і що робити в Laravel вже зараз"
url: https://laravelukraine.com/blog/bcrypt-i-72-baiti-dva-rfc-dlia-php-87-i-shho-robiti-v-laravel-vze-zaraz
date: 2026-11-10
---

# Bcrypt і 72 байти: два RFC для PHP 8.7 і що робити в Laravel вже зараз

Bcrypt, яким Laravel хешує паролі за замовчуванням, бере до уваги лише перші 72 байти пароля і мовчки відкидає решту. У PHP internals зараз одночасно обговорюють два RFC, що мають це виправити на рівні мови: [`PASSWORD_BCRYPT_SHA256`](https://wiki.php.net/rfc/bcrypt_sha256) і [`PASSWORD_BCRYPT_WRAP`](https://wiki.php.net/rfc/password_bcrypt_wrap). Обидва націлені на PHP 8.7, обидва ще в обговоренні, і дат голосування в жодного немає.

Перший RFC ми коротко згадували в [огляді жовтневих пропозицій для PHP 8.7](https://laravelukraine.com/blog/php-87-iaki-rfc-obgovoriuiut-u-zovtni-i-za-shho-vze-golosuiut). Відтоді з'явився конкурент, тож час розібратися, у чому проблема і чим підходи відрізняються.

### У чому проблема

Алгоритм bcrypt має жорстку межу в 72 байти. Усе, що далі, не впливає на хеш, тож два різні паролі з однаковими першими 72 байтами вважаються однаковими:

```php
  $hash = password_hash(str_repeat('a', 72) . 'перший', PASSWORD_BCRYPT);

  password_verify(str_repeat('a', 72) . 'зовсім інший', $hash); // true
```

Для звичайного пароля з восьми-дванадцяти символів це не має значення. Але 72 байти - не 72 символи. Кирилична літера в UTF-8 займає два байти, емодзі - чотири, тож парольна фраза українською впирається в межу вже на 36 літерах. А ще небезпечніше, коли в `password_hash()` потрапляє не сам пароль, а склеєний рядок: префікс, ідентифікатор, інший хеш. RFC про виняток для довгих паролів наводить як приклад [CVE-2025-68402 у FreshRSS](https://wiki.php.net/rfc/bcrypt_max_password_length): 64-символьний nonce перед 60-символьним хешем заповнював увесь 72-байтний простір статичними даними, і перевірка проходила з будь-яким паролем.

Друга вада - NUL-байт. На рівні `crypt()` і Blowfish рядок на деяких C-бібліотеках обрізається на першому `\0`. Сам `password_hash()` із `PASSWORD_BCRYPT` такий пароль уже не приймає і кидає `ValueError` ("Bcrypt password must not contain null character"). Проблема виринає тоді, коли розробник сам робить попереднє хешування з сирим бінарним виходом, наприклад `hash('sha256', $password, true)`: у такому рядку NUL-байт трапляється часто.

### Чому попередні спроби не пройшли

Обидва RFC починаються з переліку того, що вже пробували:

- **Кидати помилку на довгих паролях.** [RFC Сьорда Лангкемпера](https://wiki.php.net/rfc/bcrypt_max_password_length) пропонував `E_DEPRECATED` у PHP 8.7 і `ValueError` у PHP 8.8 для паролів довших за 72 байти. Він досі у статусі чернетки: будь-яка зміна поведінки `PASSWORD_BCRYPT`, під яким у продакшн-базах лежать мільйони хешів, викликає опір, а відмова приймати раніше прийнятний вхід - сама по собі порушення сумісності.
- **Виправити NUL-байти в реалізації Blowfish.** [PR #21675](https://github.com/php/php-src/pull/21675) досі відкритий - з тих самих міркувань.
- **Зробити `PASSWORD_DEFAULT` алгоритмом Argon2.** Argon2 потребує зовнішньої залежності, яка є не в кожній збірці PHP, а bcrypt вбудований завжди і досі вважається одним із найкращих для інтерактивної автентифікації.

Тож обидва нові RFC йдуть іншим шляхом: не чіпають `PASSWORD_BCRYPT`, а додають окремий опційний алгоритм, який спершу хешує пароль швидкою функцією, а результат у текстовому кодуванні віддає bcrypt. Вхід bcrypt тоді завжди має фіксовану довжину і ніколи не містить NUL-байта.

### PASSWORD_BCRYPT_SHA256: формат Passlib

[RFC Сьорда Лангкемпера](https://wiki.php.net/rfc/bcrypt_sha256) (реалізація - [PR #24073](https://github.com/php/php-src/pull/24073)) переносить у PHP алгоритм `bcrypt_sha256` з Python-бібліотеки Passlib у його другій версії. Пароль проходить через HMAC-SHA256, де ключ - сіль bcrypt, 32 байти результату кодуються в Base64 (44 символи) і йдуть у bcrypt:

```php
  $hash = password_hash($password, PASSWORD_BCRYPT_SHA256);
  // $bcrypt-sha256$v=2,t=2b,r=12$n79VH.0Q2TMWmt3Oqt9uku$Kq4Noyk3094Y2QlB8NdRT8SvGiI4ft2

  password_verify($password, $hash); // true
```

Головний аргумент - не вигадувати нічого свого. Алгоритм уже пройшов публічну перевірку, а хеші, створені в PHP і Python, взаємозамінні: застосунки на двох мовах зі спільною базою користувачів можуть читати хеші одне одного. Вартість `cost` така сама, як у `PASSWORD_BCRYPT` (4-31, за замовчуванням 12), а `password_verify()`, `password_needs_rehash()` і `password_get_info()` працюють без змін.

### PASSWORD_BCRYPT_WRAP: свій формат і суперечка про деталі

[RFC Андрея Андреєва (Andrey Andreev)](https://wiki.php.net/rfc/password_bcrypt_wrap) з [PR #25040](https://github.com/php/php-src/pull/25040), який поки позначений як WIP, з'явився 29 жовтня і відкрито полемізує з першим. Конструкція та сама, деталі інші:

- дві функції попереднього хешування на вибір через опцію `prehash`: `cshake256` (за замовчуванням) і `sha512` у вигляді HMAC;
- 54 байти результату в Base64 займають рівно 72 байти, тобто весь простір bcrypt;
- формат [PHC Strings](https://c2sp.org/phc-strings) зі стандартним Base64 замість формату Passlib;
- ліміт пароля 1024 байти проти явного зловживання.

```php
  $hash = password_hash($password, PASSWORD_BCRYPT_WRAP, ['prehash' => 'sha512']);
  // $bcrypt-wrap$v=0$cost=12,prehash=sha512$<сіль>$<хеш>
```

Розділ FAQ пояснює, чому автор вважає `bcrypt-sha256` гіршим. SHA-256 - 32-бітна функція, яку дешево перебирати на GPU, тоді як 64-бітні SHA-512 і cSHAKE256 стають вузьким місцем для зломщика безкоштовно для захисника. Формат Passlib базується на застарілому Modular Crypt Format, несумісному з PHC. Сама Passlib, за даними RFC, роками стояла без супроводу, і власник пакета змінився лише наприкінці жовтня 2026 року.

Окремо RFC розбирає **shucking** (hash shucking): атаку, за якої зломщик ламає не весь bcrypt, а внутрішній швидкий хеш, підставляючи готові хеші з чужих витоків. Від неї рятує те, що внутрішній хеш залежить від унікального ключа. У `bcrypt-wrap` це сіль із префіксом `bcrypt-wrap:0:`, що прив'язує внутрішній хеш і до конкретного bcrypt, і до версії формату. `bcrypt-sha256` у нинішній версії теж використовує сіль як ключ HMAC, але в RFC нагадують, що Passlib була вразлива до shucking до версії 1.7.3 2020 року.

Вибір між двома RFC - не стільки про безпеку, скільки про пріоритети: сумісність із наявною екосистемою проти формату, спроєктованого з нуля під сучасні стандарти. Хеші обох довші за звичайні 60 символів bcrypt (у `bcrypt-wrap` - 93-97 символів), але стандартна колонка `password` у Laravel - це `string`, тобто `VARCHAR(255)`, тож місця вистачить.

### Що робить Laravel сьогодні

Драйвер за замовчуванням у `config/hashing.php` - `bcrypt`, і [`BcryptHasher`](https://github.com/laravel/framework/blob/13.x/src/Illuminate/Hashing/BcryptHasher.php) викликає `password_hash()` з `PASSWORD_BCRYPT` без жодного попереднього хешування. Перевірка довжини в ньому є, але вимкнена: опція `limit` береться з `BCRYPT_LIMIT` і за замовчуванням дорівнює `null`.

```php
  'bcrypt' => [
      'rounds' => env('BCRYPT_ROUNDS', 12),
      'verify' => env('HASH_VERIFY', true),
      'limit' => env('BCRYPT_LIMIT', null),
  ],
```

Якщо задати `BCRYPT_LIMIT=72`, `Hash::make()` для довшого рядка кине `InvalidArgumentException`. Перевірка рахує саме байти (`strlen()`), і вона стосується тільки `make()`: на `Hash::check()` уже збережених хешів вона не впливає. Пароль із NUL-байтом `Hash::make()` і без цього не прийме: `ValueError` від PHP перехоплюється і перетворюється на `RuntimeException` з не надто очевидним текстом "Bcrypt hashing not supported."

Пастка - у валідації. Правило `max:72` рахує символи через `mb_strlen()`, а не байти, тож для кириличного пароля воно пропустить до 144 байтів. Якщо хочете чесно обмежити довжину на реєстрації, перевіряйте байти:

```php
  'password' => [
      'required',
      Password::defaults(),
      function (string $attribute, mixed $value, Closure $fail) {
          if (strlen($value) > 72) {
              $fail('Пароль задовгий: максимум 72 байти.');
          }
      },
  ],
```

### Коли має сенс argon2id

Laravel уже підтримує драйвери `argon` і `argon2id`, і вони обмеження в 72 байти не мають. Перевірте, чи ваша збірка PHP вміє Argon2: `password_algos()` має повернути `argon2id`. У Docker-образах і на хостингах це не гарантовано, саме тому PHP досі не зробив Argon2 типовим.

Перехід на `HASH_DRIVER=argon2id` у наявному застосунку потребує уваги. З `HASH_VERIFY=true`, як у типовому конфігу, `Argon2IdHasher::check()` на старому bcrypt-хеші кидає `RuntimeException` ("This password does not use the Argon2id algorithm."), тобто користувачі зі старими паролями не зможуть увійти. На час міграції `verify` треба вимкнути: тоді `password_verify()` перевірить хеш будь-якого алгоритму, а `rehash_on_login` перехешує пароль у новий формат при наступному вході.

### Що робити зараз

- **Не склеюйте нічого з паролем** перед `Hash::make()`. Префікси, ідентифікатори й інші хеші з'їдають 72-байтний бюджет і можуть зробити хеш незалежним від пароля.
- **Перевіряйте довжину в байтах**, а не в символах, якщо обмежуєте пароль на реєстрації, і подумайте про `BCRYPT_LIMIT=72` як про другий запобіжник.
- **Не робіть власного попереднього хешування** з `hash(..., true)`: сирий бінарний вихід із NUL-байтами - саме та помилка, від якої захищають обидва RFC.
- **Argon2id** - розумний вибір для нового проєкту, якщо збірка PHP його підтримує. Для наявного - тільки з продуманою міграцією.
- **Стежте за обговоренням.** Обидва RFC змінюються: `bcrypt-sha256` оновився 1 листопада, `bcrypt-wrap` - 2 листопада. Якщо вам є що сказати, гілки [про bcrypt-sha256](https://news-web.php.net/php.internals/132784) і [про bcrypt-wrap](https://news-web.php.net/php.internals/132963) відкриті. А після ухвалення одного з них ще знадобиться підтримка нового алгоритму у фреймворку - про неї поки нічого не відомо.
