bcrypt 72-Byte Password Error: Why Short Passwords Fail Too
Two different problems produce the same message, and only one of them has anything to do with your password.
If your password really is over the bcrypt 72 bytes limit, bcrypt reads the first 72 bytes and discards the rest. We hashed two 82-byte passwords that shared their first 72 bytes under a fixed salt. Both produced $2a$10$abcdefghijklmnopqrstuu.hioaszd4nGKdJlcuRzR1xqPIcN/X.S, and bcrypt.compareSync(p2, hash(p1)) returned true. The second password logs into the first password’s account.
If your password is obviously short and you still get this:
password cannot be longer than 72 bytes, truncate manually if necessary (e.g. my_password[:72])
then the message is wrong about the cause. Under passlib 1.7.4 with bcrypt 5.0.0, a 14-byte password raises it.
The culprit there is a fixed 255-byte self-test probe inside passlib. It runs once, when the backend initialises, before your password reaches the hashing call at all. bcrypt 5.0.0 rejects the probe, the exception escapes, and you read a complaint about a password nobody typed.
The __about__ monkey patch that dominates search results for this error does not fix it. We re-ran it in a clean process with the patch applied before import passlib, and the ValueError came back unchanged.
30-second triage: which one are you
| Your password | When the error fires | Root cause | Go to |
|---|---|---|---|
| Longer than 72 bytes | When you call hash | Genuinely too long. bcrypt 5.0 raises, bcrypt 4.x truncates silently | Sections 2 and 3 |
| Under 72 bytes, using passlib | On the first call in the process | passlib’s 255-byte probe. Nothing to do with your password | Section 4 |
| Contains Chinese, Japanese, or emoji | Looks short, is not | Characters are not bytes | Section 3 |
| Started failing after a dependency upgrade | After deploy | The bcrypt 5.0 breaking change | Sections 4 and 5 |
If you are in row 2, skip ahead. Nothing in the next two sections will help you, and the fix is different.
What bcrypt’s 72-byte limit does to your password
Why bcrypt stops at 72
bcrypt is built on Blowfish, and it feeds your password in as the Blowfish key. Blowfish expands its key into a P-array of 18 subkeys, each 32 bits wide. That is 18 × 4 = 72 bytes of key material, and the expansion loop wraps back to the start of the key once it has filled all 18 slots.
The ceiling is structural rather than a lazy implementation or a buffer someone forgot to raise. Every conforming bcrypt implementation on every platform has the same limit, which is why you see the number 72 in Python, Node, Go, Java, and PHP alike.
Two different passwords, one hash
bcrypt password truncation is a security property rather than a length inconvenience, which is what makes this worth checking on your own stack.
Using bcryptjs 3.0.3 with the fixed salt $2a$10$abcdefghijklmnopqrstuv, we hashed two passwords of 82 bytes each:
| Password | Value | Bytes |
|---|---|---|
| p1 | "A"×72 + "XXXXXXXXXX" | 82 |
| p2 | "A"×72 + "ZZZZZZZZZZ" | 82 |
Both produced the same digest:
$2a$10$abcdefghijklmnopqrstuu.hioaszd4nGKdJlcuRzR1xqPIcN/X.S
Two different passwords, one hash. The consequence:
bcrypt.compareSync(p2, hash(p1)) // true
An attacker who knows the first 72 bytes of a long passphrase can append anything at all and authenticate. Every byte past the boundary contributes exactly zero to the strength of the stored hash, no matter how carefully your users chose them. If you want to check a hash you already have against a candidate password without wiring up a script, you can generate and verify bcrypt hashes in the browser and watch the same behaviour yourself.
Where the boundary falls
We narrowed the cutoff one byte at a time, keeping a shared prefix and changing exactly one byte after it:
| Identical prefix bytes | Byte N+1 differs at | Same hash? |
|---|---|---|
| 70 | 71 | false |
| 71 | 72 | false |
| 72 | 73 | true |
| 73 | 74 | true |
Byte 72 still counts. Byte 73 is the first one that does not. The cutoff is abrupt, with no partial mixing on either side of it, and the same comparison is cheap to run against your own library.
Characters are not bytes
bcrypt counts UTF-8 bytes, and your users type characters. For ASCII the two numbers happen to coincide, which is exactly why this bites teams the moment they ship outside an English-speaking market.
| Character type | Example | Bytes per character | 72 bytes equals |
|---|---|---|---|
| ASCII Latin letters | A | 1 | 72 characters |
| Chinese Han characters | 密 | 3 | 24 characters |
| Japanese kana | あ | 3 | 24 characters |
| Emoji | 🔒 | 4 | 18 characters |
| Cyrillic | я | 2 | 36 characters |
| German umlauts | ü | 2 | 36 characters |
We confirmed both extremes: with a Chinese password, differences after the 24th character are ignored (true), and with an emoji password, differences after the 18th are ignored (true).
A 25-character Chinese passphrase looks generous in a password field. It has already crossed the line. A user who picks 20 emoji has been over the limit for two characters and will never be told.
Measuring byte length in your own code
Length checks written against character counts will pass while the underlying value is already too long. Measure bytes:
# Python
len(pw.encode("utf-8"))
// Node.js
Buffer.byteLength(pw, "utf8")
// Go
len([]byte(pw))
In browsers without Buffer, new TextEncoder().encode(pw).length gives the same number. Put this check in front of your hashing call and return a real validation message, rather than letting the library decide for you at 3 a.m. If you are also revisiting your minimum-length policy while you are in there, how password strength is actually measured covers what a length rule buys you and what it does not.
Why short passwords fail too: passlib’s 255-byte probe
Your password is fourteen characters long and the library insists it is over 72 bytes. This is the case that sends most people to a search engine.
Reproducing it
Three lines, on Python 3.14.5 with bcrypt 5.0.0 and passlib 1.7.4:
from passlib.hash import bcrypt
bcrypt.hash("short-password") # 14 bytes
# ValueError: password cannot be longer than 72 bytes, truncate manually if necessary (e.g. my_password[:72])
Fourteen bytes in, a complaint about 72 bytes out. The passlib bcrypt error is real, but the number in it describes something else entirely.
The full call stack
Traced through passlib 1.7.4, this is what runs:
- The first call triggers backend initialisation:
_calc_checksum→_stub_requires_backend()→set_backend(). _load_backend_mixinreadsbcrypt.__about__.__version__. The attribute does not exist, so anAttributeErroris raised. passlib swallows it and prints(trapped) error reading bcrypt version.- Initialisation continues into
_finalize_backend_mixin(passlib/handlers/bcrypt.py:421), which callsdetect_wrap_bug(IDENT_2A). detect_wrap_bug(same file,:378) verifies a fixed 255-byte probe.- bcrypt 5.0.0 raises
ValueErrorfor anything over 72 bytes, so the probe blows up on itself. - The exception propagates out to your call site. You see a message about 72 bytes that was never about your input.
The whole sequence happens once per process, on the first hash or verify. That is why the failure is so reliably reproducible and so completely insensitive to what you pass in.
What the probe looks like
secret = (b"0123456789" * 26)[:255]
That constant comes from the wraparound bug in BSD’s bcrypt that Openwall disclosed in 2012, where long keys wrapped around and collapsed into weaker hashes. passlib checks at startup whether the backend it just loaded carries that flaw, and refuses to trust a backend that does.
detect_wrap_bug is not a passlib bug. It is defensive code doing exactly what it was written to do, using a test vector that has been valid for over a decade. What changed is that bcrypt 5.0.0 now treats a 255-byte input as an error rather than something to hash, which turns a passing self-test into an uncatchable one. The pyca/bcrypt discussion in issue #1082 covers the collision between the two libraries.
Why the __about__ patch does not fix it
Search this error and you will be told, over and over, that bcrypt removed __about__ and that restoring it repairs passlib. Both halves of that are wrong, and the measurement shows it:
| Version | hasattr(bcrypt, "__about__") | Prints trapped warning | passlib works |
|---|---|---|---|
| bcrypt 5.0.0 | False | Yes | No (ValueError) |
| bcrypt 4.3.0 | False | Yes | Yes |
bcrypt 4.3.0 has no __about__ either. It prints the same (trapped) error reading bcrypt version line. And passlib runs on it without complaint. The missing attribute is therefore not the dividing line between working and broken. The ValueError behaviour change in 5.0.0 is.
Which means the popular patch cannot work, and it does not:
import bcrypt, types
bcrypt.__about__ = types.SimpleNamespace(__version__=bcrypt.__version__) # before importing passlib
from passlib.hash import bcrypt as pl
pl.hash("short-password")
# still ValueError: password cannot be longer than 72 bytes, ...
We ran this in a clean process, with the patch applied before import passlib, precisely so nobody can attribute the failure to import ordering. It still fails. All the patch achieves is silencing a harmless warning. The 255-byte probe in step 4 is a separate stage that never consulted __about__ in the first place, and it detonates either way.
What bcrypt 5.0 actually changed
The bcrypt 5.0 breaking change is one line of behaviour with a large blast radius:
| Input | bcrypt 4.3.0 | bcrypt 5.0.0 |
|---|---|---|
| 72 bytes | OK | OK |
| 73 bytes | OK (silently truncated) | ValueError |
| 100 bytes | OK (silently truncated) | ValueError |
| 255 bytes | OK (silently truncated) | ValueError |
The truncation in the 4.x column is not a figure of speech. Under 4.3.0, hash(73 bytes) and hash(100 bytes) built from the same prefix come out equal: true.
So bcrypt 5.0 is the more correct library here. Quietly discarding key material is a worse outcome than refusing to proceed, and refusing to proceed is what a hashing library should do when it cannot honour the input it was given. That does not make the upgrade painless. Code that was silently losing bytes for years now throws, and if that code path sits behind passlib, it throws before your input is even involved.
The damage from that table lands differently depending on how you call bcrypt. If you call it directly, the upgrade is visible: you get an exception on registration or login, at a code location you own, with a stack trace pointing at your own hashing call. Add a byte-length check in front of it and you are done in an afternoon.
If you go through passlib, the upgrade is invisible until it is total. The failure is not proportional to how many of your users have long passwords, because it does not depend on user input at all. Every hash and every verify in the process fails, from the first call onward, on a codebase where nothing about password handling changed. That is why this shows up as a deployment incident rather than a bug report, and why the error text sends people looking in exactly the wrong place.
Fixing it
If you can change the code
Drop passlib and call bcrypt directly. passlib’s last release was 1.7.4 and the project has been quiet for a long time, so the layer buys you very little on a project that only needs bcrypt:
import bcrypt
password = "correct horse battery staple".encode("utf-8")
hashed = bcrypt.hashpw(password, bcrypt.gensalt(rounds=12))
bcrypt.checkpw(password, hashed) # True
hashpw and checkpw both take bytes, so encode at the boundary and keep the rest of your code working in str. There is no backend detection and no self-test probe, so nothing can fail on a password you never supplied. If you want to eyeball the resulting hash, or verify one your app produced, the bcrypt generator runs entirely in your browser. Servers using bcrypt for HTTP Basic Auth have the same structural constraint in a different file format, which the htpasswd guide walks through.
If you cannot change the code today
Pin below 5:
bcrypt<5
We verified bcrypt 4.3.0 with passlib 1.7.4 and it works. It is a tourniquet rather than a repair, though. You are staying on a version whose behaviour with a bcrypt long password is to silently discard bytes, which is the exact problem 5.0 was released to stop. Put a date on the pin and plan the move.
If your users really do type long passphrases
Hash the password once with SHA-256 first, base64-encode the digest, then feed that to bcrypt:
import base64, hashlib, bcrypt
def prehash(password: str) -> bytes:
return base64.b64encode(hashlib.sha256(password.encode("utf-8")).digest())
hashed = bcrypt.hashpw(prehash(pw), bcrypt.gensalt(rounds=12))
bcrypt.checkpw(prehash(pw), hashed)
The output is always 44 bytes, comfortably under 72, whatever the input length. And it restores the property truncation destroyed: the two 82-byte passwords from the opening section, run through this, give checkpw(prehash(p2), hash(prehash(p1))) = False. The collision is gone.
The base64 step is doing real work, so do not drop it. A raw SHA-256 digest is arbitrary binary and can contain NUL bytes, which bcrypt implementations handle inconsistently. base64 gives you a NUL-free ASCII string of fixed length. Apply the same function on registration and on login, or every existing hash stops verifying.
What not to do
The __about__ monkey patch does not work. Section 4 has the measurement. If someone on your team is about to paste it in, that table will save them an afternoon.
Truncating with pw[:72] yourself is worse than doing nothing. It converts a loud failure back into a silent one, and it recreates the collision from section 2 in your own code. You would be hand-rolling the behaviour bcrypt 5.0 now refuses to perform, and unlike the library, your version will never warn anyone. If you need long passwords to work, pre-hash. If you do not, validate the byte length and reject with a clear message.
What about hashes already in your database
Which rows are affected
Only accounts whose owners registered with a password over 72 bytes. For most consumer products that is a small set, and for anything ASCII-only it usually means passphrase enthusiasts. For products with users typing Chinese, Japanese, or emoji, section 3 applies and the affected set can be much larger than a byte-blind audit suggests.
You cannot identify these rows from the hashes. A bcrypt digest is fixed-width and carries no record of how long its input was. If you logged password length at registration, that log is your only inventory. Most teams have not, and reconstructing it after the fact is not possible, so plan around not knowing rather than around a list.
You cannot recompute in bulk
There is no plaintext to re-hash, which is the entire point of storing hashes. So the migration has to be lazy: upgrade each account the next time its owner successfully authenticates, while you briefly hold the plaintext in memory.
def login(user, password: str) -> bool:
if not verify_legacy(password, user.password_hash):
return False
if needs_rehash(user.password_hash):
user.password_hash = hash_new_scheme(password)
save(user)
return True
Verify with the old scheme first, and only then re-hash. Reversing those two steps rewrites the stored hash before you have confirmed the password was correct. Store a scheme identifier alongside each hash so needs_rehash is a field comparison rather than a guess, and expect a long tail of dormant accounts that never log in. Those you handle at password reset, not by force.
When a full migration is worth it
If you are already writing the lazy-rehash path, that is the cheapest moment you will ever get to change the algorithm underneath it. The 72-byte ceiling does not exist in Argon2id, and the in-depth comparison of Argon2id and bcrypt covers when the switch pays for itself and when staying on bcrypt is the right call. The OWASP Password Storage Cheat Sheet is the reference to check your parameters against.
Do not start a migration solely because of this error. If your passwords are comfortably under 72 bytes, bcrypt remains a sound choice and section 6 already fixed your problem.
FAQ
Why does bcrypt say my password is longer than 72 bytes when it is short?
Because the bcrypt error is about passlib’s internal probe, not your password. On the first call, passlib runs detect_wrap_bug with a fixed 255-byte test string. bcrypt 5.0.0 raises ValueError for anything over 72 bytes, so the probe fails and the error surfaces at your call site. A 14-byte password triggers it.
Does bcrypt really ignore everything after 72 bytes?
Yes — bcrypt ignores every byte after 72, completely. Two 82-byte passwords sharing their first 72 bytes produce the identical hash $2a$10$abcdefghijklmnopqrstuu.hioaszd4nGKdJlcuRzR1xqPIcN/X.S, and each verifies against the other’s hash. The boundary is exact: a difference at byte 72 changes the hash, a difference at byte 73 does not.
Is the 72-byte limit a security problem?
bcrypt’s 72-byte limit is a problem for long passphrases. Anyone who knows the first 72 bytes can append arbitrary bytes and authenticate, so every byte past the limit adds nothing. For passwords under 72 bytes it changes nothing at all. Pre-hashing with SHA-256 removes the exposure if long inputs must count fully.
How many characters is 72 bytes?
72 bytes is 72 ASCII letters, but it depends on encoding: 36 Cyrillic or umlaut characters, 24 Chinese Han characters, 24 Japanese kana, or 18 emoji. bcrypt counts UTF-8 bytes rather than characters, so measure with len(pw.encode("utf-8")) in Python or Buffer.byteLength(pw, "utf8") in Node.
Does patching __about__ fix the passlib error?
No. Patching __about__ does not fix the passlib bcrypt error: we applied the patch before import passlib in a clean process and the ValueError still fired. bcrypt 4.3.0 also lacks __about__ and works fine with passlib, which proves the missing attribute is not the cause. The patch only silences the (trapped) error reading bcrypt version warning.
Should I downgrade bcrypt to below 5.0?
As a stopgap, yes: bcrypt 4.3.0 with passlib 1.7.4 works. But 4.x silently truncates anything past 72 bytes, which is the behaviour 5.0 was released to stop, so treat the pin as temporary and move to calling bcrypt directly.
Can I just truncate the password to 72 bytes myself?
Do not truncate a password to 72 bytes yourself. pw[:72] recreates the collision described above inside your own code, silently, with no library warning to catch it. Either pre-hash with SHA-256 and base64 so long inputs stay distinct, or validate the byte length up front and reject with a clear error message.
What happens to passwords already hashed before I fixed this?
Existing bcrypt hashes keep verifying, because your verify path truncates the same way the hash path did. Only accounts registered with over-72-byte passwords are weakened, and you cannot recompute them without plaintext. Re-hash lazily on the next successful login, and handle dormant accounts at password reset.