bcrypt's 72-Byte Truncation: The Password Hashing Bug Hiding in Plain Sight
Here are two different passwords:
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaathis-suffix-can-say-anything-at-all
To bcrypt, these are the same password. Not "similar", not "collide with negligible probability", the same. bcrypt will happily verify the second string against a hash generated from the first, because it never looks past the 72nd byte of either one. This isn't a bug in the sense of a coding mistake somewhere; it's baked into the algorithm's design, and most people using bcrypt every day have no idea it's there.
Proving it
Go's golang.org/x/crypto/bcrypt is a well-behaved implementation, so it's a convenient way to demonstrate the behaviour rather than a counter-example to it:
package main
import (
"fmt"
"strings"
"golang.org/x/crypto/bcrypt"
)
func main() {
prefix := strings.Repeat("a", 72)
hash, err := bcrypt.GenerateFromPassword([]byte(prefix), bcrypt.DefaultCost)
if err != nil {
panic(err)
}
imposter := prefix + "this suffix can say anything at all"
err = bcrypt.CompareHashAndPassword(hash, []byte(imposter))
fmt.Println(err) // nil: bcrypt only ever looked at the first 72 bytes
}
CompareHashAndPassword returns nil, meaning "match". The 36-byte suffix on the imposter password is completely invisible to bcrypt. If your application layers any logic on top that assumes a successful bcrypt comparison means the entire supplied string was checked, that assumption is wrong.
Where 72 actually comes from
bcrypt isn't a hash function in the SHA-2 sense; it's Niels Provos and David Mazieres' "eksblowfish" key schedule wrapped around the Blowfish cipher, run for 2^cost iterations and then used to encrypt a fixed string. The password isn't hashed, it's used as key material to set up Blowfish's internal state.
Blowfish's state consists of an 18-entry P-array plus four 256-entry S-boxes, each entry a 32-bit word. During key setup, the password bytes are cycled through and XORed into the P-array, one 4-byte chunk at a time. Eighteen entries of four bytes each gives you exactly 72 bytes of P-array to fill. Once it's full, bcrypt's setup routine has nowhere left to put the remaining bytes of a longer password, so they simply never get read. This is the standard, widely cited explanation for the limit, and it matches how the reference implementation and every compatible port actually behaves, even if it isn't spelled out explicitly in Provos and Mazieres' original paper.
The mildly interesting bit: Blowfish on its own supports key sizes from 32 up to 448 bits, that is, 4 to 56 bytes, a smaller ceiling than bcrypt's 72. bcrypt's larger cap isn't Blowfish's own key-length limit reappearing; it's specific to how eksblowfish folds the password into the P-array during its own setup phase. So "bcrypt truncates at 72 bytes" and "Blowfish keys go up to 56 bytes" are two separate facts about two related but distinct things, and it's easy to see one number quoted and assume it explains the other.
Why this is worse than it sounds
72 bytes sounds generous until you remember that "byte" and "character" are not the same thing once you leave ASCII. A passphrase built from Chinese characters, Cyrillic, or emoji can burn 2 to 4 bytes per character. A user who types a diligent 40-character passphrase using an emoji or two per word might only get the first 20 or so characters actually checked, with the rest silently discarded. Nobody tells them this. The password field accepts the input, the account gets created, and the effective entropy of their password is a fraction of what they think they typed.
It also matters for anyone concatenating secrets before hashing, a pattern that shows up more than it should: application-level pepper values, tenant IDs, or other prefixes glued onto the front of a user's password before it reaches bcrypt. If the combined length creeps past 72 bytes, the user's actual password stops contributing to the hash at all, and every account sharing that prefix and cost effectively hashes to a function of the pepper alone.
How Go actually handles it
Go's bcrypt package used to silently truncate like every other implementation. It no longer does, for one of the two entry points:
bcrypt.GenerateFromPasswordreturnsbcrypt.ErrPasswordTooLongif the input exceeds 72 bytes, rather than quietly hashing a truncated version of it.bcrypt.CompareHashAndPassworddoes not perform this check. It reads at most the first 72 bytes of whatever you give it and compares against the stored hash, exactly as the demo above shows.
That asymmetry is deliberate rather than an oversight: hashes generated years ago, by this library or another one, were produced against a silently truncated password, and CompareHashAndPassword has to reproduce that truncation to verify them correctly. Rejecting long inputs at generation time is a genuine improvement; you can't retroactively apply the same rejection to verification without breaking every existing account whose password happened to be long.
The practical upshot is that you shouldn't rely on the library to enforce a consistent policy for you. Validate password length yourself, in bytes, at the point you accept it, on both the signup and login paths, and reject or reduce it consistently rather than discovering later that one code path enforces a limit the other doesn't.
func checkPasswordLength(password []byte) error {
if len(password) > 72 {
return fmt.Errorf("password must be 72 bytes or fewer, got %d", len(password))
}
return nil
}
If you need to keep bcrypt but support longer passphrases
Rejecting long passwords outright is the honest option, but if you want to accept arbitrarily long passphrases without discarding the tail of them, the usual pattern is to pre-hash the password with a fixed-output function before handing it to bcrypt, then feed bcrypt the hash's textual encoding rather than the raw digest:
import (
"crypto/sha256"
"encoding/hex"
"golang.org/x/crypto/bcrypt"
)
func hashLongPassword(password []byte, cost int) ([]byte, error) {
sum := sha256.Sum256(password)
encoded := hex.EncodeToString(sum[:]) // 64 ASCII bytes, always within the 72-byte cap
return bcrypt.GenerateFromPassword([]byte(encoded), cost)
}
Two details matter here. First, hex-encode (or base64-encode) the digest rather than passing the raw 32 bytes through: a raw SHA-256 digest is arbitrary binary and can contain a NUL byte, and some bcrypt ports still handle passwords as C strings internally, truncating at the first NUL rather than at 72 bytes. Encoding to a fixed ASCII representation sidesteps that whole class of implementation quirk. Second, this changes the security property slightly: you're now hashing a fixed-length pre-image of the password, so a compromise of the SHA-256 step becomes relevant in a way it wasn't before. In practice this is a reasonable trade for bcrypt specifically, since SHA-256 is not the weak link in that chain.
The more direct fix, if you're not locked into bcrypt for compatibility reasons, is to use something like Argon2id, which doesn't have an equivalent hard byte ceiling baked into its key schedule. But if bcrypt is what your stack already uses and you can't move, knowing exactly where the 72-byte wall is, and validating against it explicitly rather than trusting the library to protect you on every code path, is the difference between a known constraint and a silent one.
Sources: golang.org/x/crypto/bcrypt documentation, the commit that added ErrPasswordTooLong, and golang/go issue #36546 discussing the CompareHashAndPassword truncation behaviour.