rfc:password_bcrypt_wrap

PHP RFC: Add PASSWORD_BCRYPT_WRAP

Introduction

Bcrypt's 72-byte limit on passwords and issue with null bytes are inviting many userspace workarounds, poor tradeoffs, security vulnerabilities, UX issues, and considerable toil for developers. Attempts to (partially or fully) solve these problems have so far failed, for reasons ranging from concerns over backwards-compatibility and degraded UX, to external dependencies being prohobitive of another default algorithm. Contributing to the stalemate are also prominent opinions that Bcrypt still offers best-in-class protection against hash cracking, when resource constraints of web applications are considered.

There is (not PHP-specific) consensus that a wrapping, prehashed-bcrypt construct is the solution, but no particular choice for the prehash primitive has emerged as de facto standard. Some are suboptimal in strength, others are handicapped by implementers' choices. Each has its critics, or at least enough imperfection to get the “another hash is better” reaction.

This RFC also proposes a prehashed-bcrypt construct, but puts focus on interoperability and eliminating grounds for criticism. Wherever applicable, decisions are made in favor of portability and authoritative standards. The overall pattern is the same, the details are what make the difference.

Proposal

Add a new algorithm “bcrypt-wrap” (value of sole new constant PASSWORD_BCRYPT_WRAP), which has the following characteristics:

  • Inherits default cost from PASSWORD_BCRYPT_DEFAULT_COST.
  • Supports two prehash primitives, chosen through a prehash option: cshake256 (default) and sha512 (using HMAC construct).
  • Fully compliant with PHC Strings (PHCs). Every recommendation is treated as a hard requirement.

Examples

<?php
$password = "A really, really long passw\x0rd, that's over 72 bytes and also includes a null byte";
 
$hash = password_hash($password, PASSWORD_BCRYPT_WRAP);
echo $hash, "\n";
// $bcrypt-wrap$v=0$cost=12,prehash=cshake256$+Thtm389lrboo7t9btjPVw$t76sKAokMxK5jjH918oNiRRXu0WWxd0
 
var_dump(password_verify($password, $hash)); // bool(true)
var_dump(password_verify($password." + bcrypt would ignore this", $hash)); // bool(false)
 
var_dump(password_get_info($hash));
/*
array(3) {
  ["algo"]=>
  string(11) "bcrypt-wrap"
  ["algoName"]=>
  string(11) "bcrypt-wrap"
  ["options"]=>
  array(2) {
    ["cost"]=>
    int(12)
    ["prehash"]=>
    string(9) "cshake256"
  }
}
*/
 
$hash = password_hash($password, PASSWORD_BCRYPT_WRAP, ["cost" => 4, "prehash" => "sha512"]);
echo $hash, "\n";
// $bcrypt-wrap$v=0$cost=4,prehash=sha512$jjAqrmWi61kah3sXIk4uLQ$Lru7YmJuOYolnxjzxJzvew2h5S7QBSQ

Technical details

The output hash looks like so:

// Total output length varies between 93 and 97 characters, assuming 1-digit version
$bcrypt-wrap$v=0$cost=<bcrypt cost>,prehash=<cshake256|sha512>$<salt>$<hash>

v       = Version
cost    = Bcrypt cost, but not 0-padded
prehash = The underlying prehash primitive
salt    = B64-encoded salt, 22 bytes (technically expandable, as the prehash isn't limited by Bcrypt)
hash    = B64-encoded Bcrypt hash, 31 bytes

Most of that is self-explanatory even without reading the PHCs basics. Here are the non-obvious parts:

  • B64 is what PHCs calls standard, unpadded Base64. It is required by the format specification.
    • Bcrypt has its own Base64 dialect with different ordering. Internal translations are performed for alignment.
    • This supports portability. Even where that's not obvious, it prevents bugs caused by naive implementations.
    • The salt specifically needs it so, because it is used for the prehash step. Bcrypt famously discards the 128th bit, making it impossible to restore.
  • Whichever prehash function is chosen, 54 bytes of binary output are B64-encoded to fill the 72-byte space allowed by Bcrypt.
    • cSHAKE256 is an eXtendable Output Function (XOF), which means it natively supports variable-length outputs. The salt goes in its customization string.
    • HMAC-SHA512 output is truncated (yes, this is perfectly fine). The salt is part of the key.
  • Not denoting the Bcrypt variant is intentional.
    • The difference between 2b and PHP's 2y is historical and not functional. They are identical and 100% portable.
    • There's no point in supporting older variants, but a B64-encoded prehash entirely sidesteps even the bugs in 2a, 2x.
    • To the extent that a future new variant may ever be relevant, it should be bound to the numeric version present in this format.
  • There's a 1024 byte limit on the password, meant to prevent extreme misuse without any complaints.
  • Unlike seen in other hashes, the parameters use full names because PHCs recommends them to be human readable.
    • This is the least important part in the entire algorithm, but would also be set in stone. If there are meaningful objections, please do bring them up.

password_hash() algorithm steps:

  1. Generate 16 bytes of random salt.
  2. Prehash input password with chosen algorithm, and salt prepended by bcrypt-wrap:0: (where 0 is current version).
  3. B64-encode prehash.
  4. Bcrypt-Base64-encode the salt.
  5. Apply standard Bcrypt over the prehash, using encoded salt from previous step.
  6. Extract Bcrypt hash segment (last 31 bytes) from the previous step.
  7. Translate Bcrypt hash segment from Bcrypt-Base64 to B64.
  8. B64-encode original (unencoded) salt.
  9. Overlay values over PHCs template.

password_verify() algorithm steps:

  1. Parse stored hash string.
  2. B64-decode extracted salt.
  3. Prehash input password with determined algorithm, and salt prepended by bcrypt-wrap:<version>: (version extracted from hash string).
  4. B64-encode prehash.
  5. Translate parsed salt and hash segments from B64 to Bcrypt-Base64.
  6. Overlay values over a Bcrypt hash template.
  7. Apply standard Bcrypt over the prehash, using Bcrypt-Base64-encoded salt from step 4.
  8. Compare hash strings created in steps 5 and 6.

Other considerations

Most of these are anticipated questions likely to be raised during discussion. If more come up, they'll be added at the bottom.

Isn't this rolling our own crypto? ™

No. This is a well-known pattern adjusted to modern standards. Every building block is standard, applied correctly, and has a meaningful purpose. As far as it is possible to know that mistakes are avoided, every choice made about the algorithm is backed by reputable public sources and not speculative. Since we are in the AI craze era, it needs to be said: no, none of this is invented by an LLM, and there are zero “hallucinations” involved.

Additionally, the target release version is PHP 8.7, more than a year away as of this first publication. That's plenty of time for peer review, including from outside experts.

Why have both cSHAKE256 and SHA-512?

The easy answer is Cryptographic agility. A secondary answer is that (in this context) the two are equal and no unbiased comparison today would have a clear winner. In absence of reasons other than someone's preference, it would be a mistake to lock onto one path.

cSHAKE256 is significantly more elegant and its adoption is rapidly increasing in Post-Quantum Cryptography, practically ensuring that it's here to stay. The bcrypt-wrap design actually evolved from a cshake256-bcrypt prototype.
SHA-512 has a slight edge in terms of having been around for longer and users are more familiar with it. It has to be used as a HMAC and truncated, but this is a provably safe technique.

While a few vanity metrics could make a head-to-head comparison more quantitative, they are completely meaningless when wrapped by Bcrypt. The one number to care about is 256 bits of collision resistance provided by both functions. A common point of confusion might be the different numbers in the function names, which don't mean the same thing. In SHA-512, the number is the output size; in cSHAKE256, the number is the security strength.

Why is the prehash strength important?

This is a fair question, as the prehash merely patches up Bcrypt's limitations. In a pragmatic sense, it's not that important. A bigger number is not good for the sake of being bigger, and there is such a thing as too much security; otherwise we'd have cost 31 Bcrypt. However, defaulting to an acceptable minimum and opposing higher standards on a “not needed” basis is also irrational, particularly when the cost is negligible. All reasons for weaker hashes to even exist are based on computational speed, which would be a drop in the ocean of what Bcrypt does.

It is a matter of keeping up with standards, having solid grounds for trust, and (with PHP's poor reputation in mind) disarming critics.

Why is cSHAKE256 default?

In addition to already provided details, SHA-512 runs on historical momentum, while cSHAKE256 would be ahead if the same history was being shaped today. This choice is the cryptographic equivalent of parameterized SQL queries over escaped string interpolation.

HMACs are well-known for the part they play in security, but the way they are used in this pattern is a double clutch. First for what the “c” (customizable) in cSHAKE naturally provides against password shucking, and second to prevent length-extension attacks, which cSHAKE is immune to by design.

What even is cSHAKE256? It sounds exotic

It might sound that way because it's not an all-familiar “SHAnumber” name, except that's what the SHA in SHAKE stands for. This is a SHA3-family function standardized by NIST; a slightly modified SHA3-512.

Wait, SHA-512 and SHA3-512? Yes, this can be confusing.
SHA-512 is from the SHA2 family, which superseded SHA1.
SHA3 was introduced to replace SHA2 in case it gets broken, and so that we can have two alternatives that can't be simultaneously broken. It warrants saying that either can fail first, or be proven to be categorically better, so that's not a factor. This is merely a historical summary and a note that we're comparing SHA2 and SHA3, not standard vs exotic.

What about other hash primitives?

The format is extensible and two options must exist for cryptographic agility, but there is no intent to support many algorithms. Aside from technical merits, SHA-512 and cSHAKE256 were selected also for their wide availability. A meaningful third option of the same strength that could (and should) be added is BLAKE2b.
The one bias that has played a role here is that SHA2 and SHA3 are already part of PHP core, and investing more effort before a conceptual proposal has approval makes little sense. If it gains traction though, consider BLAKE2b a realistic third that shouldn't be discounted (certainly not on the basis that we don't already have it; it is very popular in other ecosystems).

Overall though, a goal to have in mind is ensuring the algorithm is uniformly portable, without dropping one of its options. With two or three, that is easy to demand. If there were more, that would risk degrading interoperability.

This RFC exists in large part because Bcrypt is already in the standard library, while good alternatives to it require external dependencies. New additions that don't satisfy practical needs or can't meet the same criteria should be discouraged.

OWASP recommends SHA-384

In reference to OWASP Password Storage Cheat Sheet (archive link).

OWASP's considerations are not all about cryptographic strength. They have chosen the largest fixed-output hash that fits within 72 bytes after Base64 encoding, because their advice has to be idiomatic. It delivers practical security for the average developer in as many ecosystems as possible.

The recommendation doesn't apply to PHP, because it is PHP's role to make better things idiomatic.
A side benefit to OWASP is also not having to argue about truncation. However, SHA-384 *is* a truncated SHA-512, with different initial values for the sole purpose of not producing a literal substring.

What's wrong with bcrypt-sha256?

This question is in reference to the recent RFC proposing a port of bcrypt-sha256 from Python's PassLib.

bcrypt-sha256 is functionally solid, but inferior all around. Most notably, SHA-256 is 32-bit and computationally cheap to crunch on GPUs. 64-bit functions like SHA-512 and cSHAKE256 introduce a choke point on cracking rigs for free.

It's one of those things that gets a default assumption of maturity, but in fact has many subtle deficiencies visible to a trained eye. To people outside of cryptography though, most of them can be seen as petty and the goal here isn't to criticize. Suffice to say, PassLib is stuck with a lot of legacy that PHP has no reason to inherit.

Backward Incompatible Changes

None.

Proposed PHP Version(s)

PHP 8.7.

RFC Impact

To the Ecosystem

Developers no longer have to make a tradeoff between working around Bcrypt's limitations or potentially unavailable extensions. Code dealing with this tradeoff can be phased-out, which has the second-order effect of reduced risks in security-critical code, even beyond password handling. A lot of end users get a free security upgrade without realizing it.

To Existing Extensions

ext/standard will now depend on ext/hash, but this is a non-issue since RFC: Always available hash extension.
ext/hash ABI is affected, since it needs to expose low-level functionality for use by ext/standard/password.c

To SAPIs

None.

Open Issues

None yet.

Future Scope

Potentially become the PASSWORD_DEFAULT in a later major PHP release.

Voting Choices

Primary Vote requiring a 2/3 majority to accept the RFC:

Add PASSWORD_BCRYPT_WRAP to password_hash() as outlined in the RFC?
Real name Yes No Abstain
Final result: 0 0 0
This poll has been closed.

Patches and Tests

Implementation

After the RFC is implemented, this section should contain:

  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

Rejected Features

None.

Changelog

  • 2026-10-29: Initial version.
rfc/password_bcrypt_wrap.txt · Last modified: by narf