====== PHP RFC: Add PASSWORD_BCRYPT_SHA256 to password_hash() ======
* Version: 0.1
* Date: 2026-10-04
* Author: Sjoerd Langkemper, sjoerd-php@linuxonly.nl
* Status: Draft
* Implementation: https://github.com/php/php-src/pull/24073
* Discussion thread: tbd
* Voting thread: tbd
===== Introduction =====
This RFC proposes a new ''password_hash()'' algorithm identifier, ''PASSWORD_BCRYPT_SHA256'', which pre-hashes the password with HMAC-SHA256 before handing it to bcrypt. The format and algorithm are taken directly from Passlib's well-established ''bcrypt_sha256'' hash (format version 2), so hashes produced by PHP and by Passlib are interchangeable.
The default algorithm used by ''PASSWORD_DEFAULT'' is **not** changed by this RFC; ''PASSWORD_BCRYPT_SHA256'' is an additional, opt-in identifier.
===== Proposal =====
Plain bcrypt, as exposed via ''PASSWORD_BCRYPT'', silently truncates passwords at 72 bytes, and on some C libraries also truncates at the first NUL byte. Both quirks are a long-standing source of security bugs. The proposed password algorithm prehashes the password, thus solving these limitations of bcrypt.
==== Background: previous attempts ====
Two earlier efforts tried to address the same underlying problems without introducing a new algorithm:
* [[https://wiki.php.net/rfc/bcrypt_max_password_length|Throw error for passwords longer than 72 bytes in password_hash() with bcrypt]], which proposed rejecting over-long passwords outright instead of silently truncating them.
* [[https://github.com/php/php-src/pull/21675|Fix the implementation of BlowFish to solve bugs concerning NUL bytes truncating strings]], which attempted to fix the NUL-byte truncation at the ''crypt()''/Blowfish level.
Both approaches change the behavior of the existing ''PASSWORD_BCRYPT'' identifier, which is risky: any behavior change to an algorithm that may already have millions of stored hashes in production databases needs to be weighed very carefully, and rejecting previously-accepted input is itself a backward compatibility concern. This RFC instead proposes a new algorithm identifier that applications can opt into, leaving ''PASSWORD_BCRYPT'' untouched.
==== Why bcrypt-sha256 ====
Rather than inventing a new, PHP-specific pre-hashing scheme, this RFC proposes adopting an existing, well-reviewed and widely deployed one: Passlib's [[https://passlib.readthedocs.io/en/stable/lib/passlib.hash.bcrypt_sha256.html|bcrypt_sha256]] hash, in its current "version 2" form (the default since Passlib 1.7.3). Passlib is a mature Python password-hashing library, and its bcrypt_sha256 format is already used in production by projects such as Ansible's ''htpasswd''/''password_hash'' filters and various Python web frameworks. Reusing its format means:
* The algorithm and its properties have already received public scrutiny.
* PHP and Python applications sharing a user database (or migrating between the two ecosystems) can read and write each other's hashes directly.
* There is no need to design a new modular crypt format from scratch.
The cryptographic building blocks (HMAC, SHA256, bcrypt) are already present in PHP, so no dependencies are needed to implement bcrypt-sha256.
==== Algorithm ====
Given a password and a cost factor (''rounds'', 4–31, default 12, same default and range as ''PASSWORD_BCRYPT''):
- Generate a 22-character bcrypt salt string (the same kind of salt ordinary bcrypt uses).
- Compute ''mac = HMAC-SHA256(key = salt (the 22 ASCII characters, not the decoded bytes), message = password)'', a raw 32-byte digest.
- Base64-encode ''mac'', giving a 44-byte ASCII string (''key''). This string can never contain a NUL byte and is always far under 72 bytes, regardless of the original password's length or content.
- Compute ''raw = crypt(key, "$2y$" . zero_pad_2_digits(rounds) . "$" . salt)''.
- Take the last 31 characters of ''raw'' as the ''digest''.
- Encode the result as ''$bcrypt-sha256$v=2,t=2b,r=$$''.
Verification parses the stored hash, repeats steps 2–5 with the password being checked, and compares the resulting digest to the stored one in constant time.
Internally, PHP's bcrypt only accepts the ''$2y$'' (and legacy ''$2a$''/''$2x$'') prefixes via ''crypt()'', never ''$2b$''. Because the string handed to bcrypt here is always the fixed-size 44-byte base64 key, well under the 72-byte boundary where the ''2a''/''2b''/''2x''/''2y'' variants could ever behave differently, computing with ''$2y$'' and labeling the output ''t=2b'' is safe and produces digests identical to a native ''2b'' implementation. This matches what Passlib itself does internally on backends lacking native ''2b'' support.
==== New constants ====
==== Behavior of existing functions ====
No new functions are introduced. ''PASSWORD_BCRYPT_SHA256'' is registered through the same internal ''php_password_algo'' mechanism used by ''PASSWORD_BCRYPT'' and ''PASSWORD_ARGON2I''/''PASSWORD_ARGON2ID'', so it transparently works with all existing password API functions:
* ''password_hash($password, PASSWORD_BCRYPT_SHA256, ["cost" => int])'' — accepts the same ''cost'' option as ''PASSWORD_BCRYPT'' (4–31, default 12). An out-of-range cost throws a ''ValueError'', exactly as it does for ''PASSWORD_BCRYPT''.
* ''password_verify($password, $hash)'' — works unchanged; the algorithm is detected from the ''$bcrypt-sha256$'' prefix.
* ''password_needs_rehash($hash, PASSWORD_BCRYPT_SHA256, $options)'' — returns ''true'' when the stored cost differs from the requested one, or when the hash cannot be parsed as a valid ''bcrypt-sha256'' hash.
* ''password_get_info($hash)'' — returns ''["algo" => "bcrypt-sha256", "algoName" => "bcrypt-sha256", "options" => ["cost" => int]]'' for a recognized hash, or the usual "unknown" result otherwise.
* ''password_algos()'' — includes ''"bcrypt-sha256"'' in its result.
==== Hash format ====
$bcrypt-sha256$v=2,t=2b,r=$$
^ Field ^ Description ^
| ''v=2'' | Format version, always ''2''. |
| ''t=2b'' | bcrypt variant label; always ''2b'', see above. |
| ''r='' | Decimal cost, not zero-padded, 4–31. |
| '''' | 22 characters, bcrypt-base64 alphabet ''./A-Za-z0-9''. |
| '''' | 31 characters, same alphabet. |
This is byte-for-byte the same layout Passlib uses, including the ''v=2,t=2b,r='' field prefixes.
==== Implementation notes ====
The proof-of-concept implementation adds the new algorithm to ''ext/standard/password.c'' and relies on SHA-256/HMAC from the bundled ''ext/hash'' extension rather than re-implementing hashing primitives:
* A new internal (''PHPAPI'') function, ''php_hash_hmac()'', is added to ''ext/hash'', computing ''HMAC(key, data)'' directly into a caller-provided buffer without the ''zval''/''zend_string'' overhead of the userland ''hash_hmac()'' API. The existing ''hash_hmac()'' implementation is refactored to use it internally.
* ''ext/hash'''s internal ''php_hash_sha256_ops'' struct is exposed via ''php_hash_sha.h'' so other extensions can reference the SHA-256 operations table directly.
* ''ext/standard'''s ''config.m4'' gains a ''PHP_ADD_EXTENSION_DEP([standard], [hash])'' build dependency. This is not a new runtime requirement for users: the hash extension has not been possible to disable at compile time since PHP 7.4 (see [[https://wiki.php.net/rfc/permanent_hash_ext|the Permanently enable hash extension RFC]]), so every PHP build already includes it.
* Salt generation, bcrypt invocation (via the existing internal ''php_crypt()''), base64 encoding, and constant-time comparison (''php_safe_bcmp()'') all reuse existing internal helpers already used by ''PASSWORD_BCRYPT''; no new cryptographic code is written for bcrypt itself.
==== Examples ====
Basic usage:
Edge case — a password longer than bcrypt's 72-byte limit is no longer truncated:
Edge case — a NUL byte in the password no longer causes silent truncation:
Interoperability with Passlib (Python):
===== Backward Incompatible Changes =====
None. ''PASSWORD_BCRYPT_SHA256'' is a new, opt-in algorithm identifier; the behavior of ''PASSWORD_BCRYPT'', ''PASSWORD_ARGON2I'', ''PASSWORD_ARGON2ID'' and ''PASSWORD_DEFAULT'' is unchanged, and no existing constant, function signature, or default value is modified.
The only build-level change is that ''ext/standard'' now declares a compile-time dependency on ''ext/hash''. Since ''ext/hash'' has been an unconditionally-enabled, bundled part of every PHP build since PHP 7.4 (it can no longer be disabled with ''--disable-hash''), this has no practical effect on users or distributors.
Bcrypt-sha256 hashes are longer than normal bcrypt hashes. Users who switch may need to increase their database column length.
===== Proposed PHP Version(s) =====
Next PHP 8.x (e.g. PHP 8.7).
===== RFC Impact =====
==== To the Ecosystem ====
IDEs, language servers, static analyzers and linters that enumerate ''password_hash()'''s ''$algo'' argument as a closed set of known constants will need to add ''PASSWORD_BCRYPT_SHA256'' to that list, the same maintenance they already perform whenever a new algorithm constant is added (as happened for ''PASSWORD_ARGON2I''/''PASSWORD_ARGON2ID''). Userland libraries that re-implement bcrypt-sha256-style pre-hashing (for interoperability with Passlib, for example) could switch to the native implementation.
==== To Existing Extensions ====
''ext/standard'' gains a build-time dependency on ''ext/hash'' (see Backward Incompatible Changes above). No other extension is affected.
==== To SAPIs ====
None. The feature is available identically in CLI, the built-in development server, FPM, and embedded SAPIs.
===== Open Issues =====
None at this time.
===== Future Scope =====
* Passlib's PBKDF2 hash would also be straightforward to implement in PHP.
* The default hash function could be changed from bcrypt to bcrypt_sha256 in the future.
===== Voting Choices =====
Primary Vote requiring a 2/3 majority to accept the RFC:
* Yes
* No
===== Patches and Tests =====
https://github.com/php/php-src/pull/24073
===== Implementation =====
To be filled in after the vote:
- PHP version(s) it was merged into
- Link to the git commit(s)
- Link to the PHP manual entry for the feature
===== References =====
* Proof-of-concept implementation: https://github.com/php/php-src/pull/24073
* Passlib documentation: [[https://passlib.readthedocs.io/en/stable/lib/passlib.hash.bcrypt_sha256.html|passlib.hash.bcrypt_sha256 - BCrypt+SHA256]]
* [[https://wiki.php.net/rfc/bcrypt_max_password_length|RFC: Throw error for passwords longer than 72 bytes in password_hash() with bcrypt]]
* https://github.com/php/php-src/pull/21675 — Fix the implementation of BlowFish to solve bugs concerning NUL bytes truncating strings
* [[https://wiki.php.net/rfc/permanent_hash_ext|RFC: Permanently enable hash extension]]
===== Rejected Features =====
* Modifying ''PASSWORD_BCRYPT'' itself to reject over-long passwords or to stop truncating at NUL bytes — considered too risky a behavioral change for an existing, widely-deployed algorithm identifier; see "Background" above.
===== Changelog =====
* 2026-10-04: Initial draft.