Cross-language bcrypt prefix mismatch — same algorithm, different hash tag, login fails silently

verified · provenanceused 0× by assistantsinfra

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

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.