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.
<?php $hash = password_hash($password, PASSWORD_BCRYPT_SHA256); // $bcrypt-sha256$v=2,t=2b,r=12$n79VH.0Q2TMWmt3Oqt9uku$Kq4Noyk3094Y2QlB8NdRT8SvGiI4ft2 var_dump(password_verify($password, $hash)); // true ?>
The default algorithm used by PASSWORD_DEFAULT is not changed by this RFC; PASSWORD_BCRYPT_SHA256 is an additional, opt-in identifier.
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.
Two earlier efforts tried to address the same underlying problems without introducing a new algorithm:
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.
Rather than inventing a new, PHP-specific pre-hashing scheme, this RFC proposes adopting an existing, well-reviewed and widely deployed one: Passlib's 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 cryptographic building blocks (HMAC, SHA256, bcrypt) are already present in PHP, so no dependencies are needed to implement bcrypt-sha256.
Given a password and a cost factor (rounds, 4–31, default 12, same default and range as PASSWORD_BCRYPT):
mac = HMAC-SHA256(key = salt (the 22 ASCII characters, not the decoded bytes), message = password), a raw 32-byte digest.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.raw = crypt(key, “$2y$” . zero_pad_2_digits(rounds) . “$” . salt).raw as the digest.$bcrypt-sha256$v=2,t=2b,r=<rounds>$<salt>$<digest>.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.
<?php namespace { const PASSWORD_BCRYPT_SHA256 = "bcrypt-sha256"; const PASSWORD_BCRYPT_SHA256_DEFAULT_COST = 12; } ?>
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.$bcrypt-sha256$v=2,t=2b,r=<rounds>$<salt>$<digest>
| Field | Description |
|---|---|
v=2 | Format version, always 2. |
t=2b | bcrypt variant label; always 2b, see above. |
r=<rounds> | Decimal cost, not zero-padded, 4–31. |
<salt> | 22 characters, bcrypt-base64 alphabet ./A-Za-z0-9. |
<digest> | 31 characters, same alphabet. |
This is byte-for-byte the same layout Passlib uses, including the v=2,t=2b,r= field prefixes.
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:
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 the Permanently enable hash extension RFC), so every PHP build already includes it.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.Basic usage:
<?php $hash = password_hash("correct horse battery staple", PASSWORD_BCRYPT_SHA256); var_dump(password_verify("correct horse battery staple", $hash)); // true var_dump(password_verify("wrong password", $hash)); // false ?>
Edge case — a password longer than bcrypt's 72-byte limit is no longer truncated:
<?php $long = str_repeat("a", 200); $hash = password_hash($long, PASSWORD_BCRYPT_SHA256); var_dump(password_verify($long, $hash)); // true var_dump(password_verify(str_repeat("a", 199), $hash)); // false, the 200th byte matters var_dump(password_verify(str_repeat("a", 72) . "different", $hash)); // false ?>
Edge case — a NUL byte in the password no longer causes silent truncation:
<?php $hash = password_hash("foo\x00bar", PASSWORD_BCRYPT_SHA256); var_dump(password_verify("foo\x00bar", $hash)); // true var_dump(password_verify("foo\x00baz", $hash)); // false var_dump(password_verify("foo", $hash)); // false — with PASSWORD_BCRYPT this could be true ?>
Interoperability with Passlib (Python):
<?php // Hash produced by Python's `passlib.hash.bcrypt_sha256.hash("password")` $hash = '$bcrypt-sha256$v=2,t=2b,r=12$n79VH.0Q2TMWmt3Oqt9uku$Kq4Noyk3094Y2QlB8NdRT8SvGiI4ft2'; var_dump(password_verify("password", $hash)); // true ?>
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.
Next PHP 8.x (e.g. PHP 8.7).
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.
ext/standard gains a build-time dependency on ext/hash (see Backward Incompatible Changes above). No other extension is affected.
None. The feature is available identically in CLI, the built-in development server, FPM, and embedded SAPIs.
None at this time.
Primary Vote requiring a 2/3 majority to accept the RFC:
To be filled in after the vote:
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.