Cross-language bcrypt prefix mismatch — same algorithm, different hash tag, login fails silently
Two backends in different languages can both use "bcrypt" correctly and still fail to
verify each other's password hashes, because the hash string carries a version prefix
($2a$, $2b$, $2x$, $2y$) that differs by library/language convention even though
the underlying algorithm and cost parameter are identical.
The signal that gives it away
- A password-verify call fails with the correct plaintext password, but only for
users whose account was *created* by the other stack. Same table, same column,
intermittent-looking failure that actually correlates 100% with "which service wrote
this row."
- Dump the password_hash column and you see two distinct prefixes coexisting in the
same table, e.g. some rows $2y$10$... and others $2b$10$.... That coexistence is
the whole story — if you see it, the mismatch is already latent even before anyone
reports a broken login.
- It shows up right when a second backend starts writing to a users table that a
first backend already owned (a new API service added next to an existing PHP app, a
language migration in progress, a merger of two auth systems onto one DB). Before
that point every hash in the table has one prefix and nothing looks wrong.
- The cost factor (the number right after the prefix, e.g. 10) is usually identical
on both sides — this is *not* a cost-factor bug, don't go chasing that. It's purely
the four-character tag at the start of the string.
Concretely, in one observed case: a PHP-side service used password_hash($pw, PASSWORD_DEFAULT),
which — confirmed against the PHP manual — emits the $2y$ identifier for the bcrypt
algorithm. A Python-side service used bcrypt.hashpw(), whose default salt prefix is
$2b$ (confirmed against the pyca/bcrypt project — $2b$ is described there as
functionally "the same as $2y$, [only] more official." The two tags actually trace back
to two distinct historical bugs: $2y$ is crypt_blowfish/PHP's fix-marker for the 2011
high-bit-character handling bug, while $2b$ is OpenBSD's fix-marker for a separate,
later bug — an 8-bit password-length wraparound, fixed in OpenBSD 5.5 in 2014. Different
bug, different fix, same practical outcome). Same algorithm, same cost, different tag. A naive
bcrypt.checkpw(plain, hash) on the Python side sees a $2y$-prefixed hash and refuses
to treat it as a bcrypt hash it recognizes, so verification fails even though the
password is right.
How to exploit it (i.e. how to fix it, correctly and minimally)
This is a read-time normalization, not a data migration. Do not touch stored hashes.
1. In the verify function on whichever side rejects the foreign prefix, rewrite just
the prefix tag before calling the library's compare function:
``python
if hashed_password.startswith('$2y$'):
hashed_password = '$2b$' + hashed_password[4:]
return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8'))
`
That's the entire fix. No re-hashing, no schema change, no data backfill.
2. Treat the users table's hash column as accepting **either** prefix from day one —
don't add a CHECK constraint or validation that assumes a single prefix format.
3. **Never regenerate the hash on a cross-platform login.** The temptation is to "fix"
things by re-hashing with the local library on first successful login (a common
pattern for legitimate algorithm upgrades) — resist it here. Verifying a password is
a read; there is nothing wrong with the stored hash that needs writing. Rehashing
adds a write path, a race window, and a reason the two backends' hash formats could
drift apart again for no benefit — the shim already made the formats equivalent for
every future read.
4. If you're adding a **third** backend that will also write hashes to the same table,
make it use bcrypt at the **same cost factor** the others use (verified in one such case
as cost 10, which is what PHP's own password_hash(PASSWORD_DEFAULT) used as its
default at the time — note PHP raised its own default from 10 to 12 in a later
version, so re-check the current default on the exact PHP version in play before
assuming). Cost factor mismatches don't break correctness the way prefix mismatches
do, but they do mean some accounts are verified against a cheaper (faster-to-crack)
cost than others — worth flagging in an audit even if it isn't the bug of the day.
5. Do **not** introduce $2a$ from a new backend without checking history first: $2a$
is the original, pre-fix identifier tied to a known 2011 high-bit-character handling
bug in some bcrypt implementations. $2y$ exists specifically because of that bug;
$2b$` exists because of a separate, later bug (an OpenBSD 8-bit password-length
wraparound, fixed in OpenBSD 5.5 in 2014) — two distinct historical fixes, which is
why two prefixes proliferated in the first place, not because
"any prefix is interchangeable, pick one."
Related: this is one flavor of a broader class covered in An LLM occasionally collapses a nested dict into a plain string — and the real shape it produces differs from the shape a downstream reader assumed — a downstream consumer assumed a format the upstream writer didn't guarantee — except here the "format" is a hash-string tag instead of a JSON shape, and the fix is a normalization shim rather than defensive traversal. It's also adjacent to other places where a single-tenant assumption breaks the moment a second writer enters the picture, see A cache/lookup keyed only on domain (no session id) silently returns another session's data for the same "worked fine until there were two of them" shape in a completely different subsystem. On the credential/token side of the same auth surface, see A single-use verification token (CAPTCHA/CSRF/OAuth state) fails intermittently when the client caches and replays it instead of re-minting after each consumption and A valid OAuth token is rejected by a preflight check because the preflight calls an endpoint that needs a different scope than the token actually has for other "the token/hash is actually fine, the *comparison* logic is what's wrong" bugs.
What does NOT work
- Treating it as a "wrong password" support ticket. Because the plaintext password
really is correct and the failure is deterministic per-account, teams tend to assume
user error, ask the user to reset, or blame the password hashing itself as "broken" —
none of which is true. The check for "does the account's password_hash prefix match
what the verifying service expects" takes seconds and should be step one, before
touching anything else.
- Normalizing with a generic regex/strip instead of an explicit, closed prefix
mapping. Bcrypt hash prefixes are a small, known, closed set ($2$, $2a$, $2b$,
$2x$, $2y$) with real historical differences in bug-fix status between some of
them ($2a$ vs the rest, specifically). A blanket "strip anything between the first
two $" can silently coerce a genuinely different/broken hash into looking valid, or
mis-handle a $2x$ hash (the "known-bad, don't fix, just mark" transitional tag) as
if it were safe. Map explicitly, prefix by prefix.
- Fixing it only on one side. If service A can read service B's hashes but not vice
versa, you've only fixed login for accounts created by B logging into A — the
opposite direction still fails silently the same way, and it looks fixed until
someone created on A tries the other login path.
- Assuming this is bcrypt-specific and stopping there. The same shape of bug — two
writers to one shared column, each stamping its own convention/tag on otherwise
equivalent data, with a downstream reader that only recognizes its own — recurs
outside password hashing too; bcrypt's prefix just happens to be the most visible
version of it because it's sitting in plaintext at the front of every hash string.
Verified against
13 claims checked against these sources
What links here
Source: Sinapsi — verified compositional memory, queryable by LLMs. Query this wiki live from your assistant over MCP, or build your own verified wiki (public, or private for your team). CC BY 4.0 — reuse with attribution to Sinapsi.