# RFC: Add PASSWORD_BCRYPT_SHA256 to password_hash

- Status: Under discussion
- Authors: Sjoerd Langkemper, sjoerd-php@linuxonly.nl
- Date: 2026-10-04
- Version: 0.1
- Implementation: https://github.com/php/php-src/pull/24073
- Original: https://wiki.php.net/rfc/bcrypt_sha256
- Page: https://externals.io/rfc/bcrypt_sha256
- More for agents: search with https://externals.io/search.md?q={query} · RFCs at https://externals.io/rfc.md · JSON API at https://externals.io/api · documentation at https://externals.io/llms.txt

## Votes

### Add PASSWORD_BCRYPT_SHA256 to password_hash() as outlined in the RFC? (closed)

Yes: 0 · No: 0 · Abstain: 0

## Discussions on the mailing list

- [\[RFC\] Add PASSWORD_BCRYPT_SHA256 to password_hash](https://externals.io/message/132784.md) — started on 2026-10-05, 12 messages

---

## 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 `bcrypt_sha256` hash, so hashes produced by PHP and by Passlib are interchangeable.

```php
<?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.

## 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:

- [Throw error for passwords longer than 72 bytes in password\_hash() with bcrypt](https://externals.io/rfc/bcrypt_max_password_length), which proposed rejecting over-long passwords outright instead of silently truncating them.
- [Fix the implementation of BlowFish to solve bugs concerning NUL bytes truncating strings](https://github.com/php/php-src/pull/21675), 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 [bcrypt\_sha256](https://passlib.readthedocs.io/en/stable/lib/passlib.hash.bcrypt_sha256.html) 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`):

1. Generate a 22-character bcrypt salt string (the same kind of salt ordinary bcrypt uses).
2. Compute `mac = HMAC-SHA256(key = salt (the 22 ASCII characters, not the decoded bytes), message = password)`, a raw 32-byte digest.
3. 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.
4. Compute `raw = crypt(key, “$2y$” . zero_pad_2_digits(rounds) . “$” . salt)`.
5. Take the last 31 characters of `raw` as the `digest`.
6. Encode the result as `$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.

### New constants

```php
<?php
 
namespace {
    const PASSWORD_BCRYPT_SHA256 = "bcrypt-sha256";
    const PASSWORD_BCRYPT_SHA256_DEFAULT_COST = 12;
}
 
?>
```

### 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=<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.

### Implementation notes

The 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 function, `php_hash_hmac()`, is added to `ext/hash` to expose HMAC functionality internally within PHP without going to the userland API.
- `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](https://externals.io/rfc/permanent_hash_ext)), 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:

```php
<?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
<?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
<?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
<?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
 
?>
```

## 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:

**Vote: Add PASSWORD\_BCRYPT\_SHA256 to password\_hash() as outlined in the RFC?** — Yes: 0 · No: 0 · Abstain: 0 (closed)

## Patches and Tests

<https://github.com/php/php-src/pull/24073>

## Implementation

To be filled in after the vote:

1. PHP version(s) it was merged into
2. Link to the git commit(s)
3. Link to the PHP manual entry for the feature

## References

- Implementation: <https://github.com/php/php-src/pull/24073>
- Passlib documentation: [passlib.hash.bcrypt\_sha256 - BCrypt+SHA256](https://passlib.readthedocs.io/en/stable/lib/passlib.hash.bcrypt_sha256.html)
- [RFC: Throw error for passwords longer than 72 bytes in password\_hash() with bcrypt](https://externals.io/rfc/bcrypt_max_password_length)
- <https://github.com/php/php-src/pull/21675> — Fix the implementation of BlowFish to solve bugs concerning NUL bytes truncating strings
- [RFC: Permanently enable hash extension](https://externals.io/rfc/permanent_hash_ext)

## Rejected Features

This RFC does intentionally not propose to modify `PASSWORD_BCRYPT` in a backwards incompatible way. This is not exactly rejected, but received criticism on the internals mailing list; see “Background” above.

## Changelog

- 2026-10-04: Initial draft.
