← Blog

October 1, 2026 · Steve Johnson

Migrating Bcrypt Password Hashes Across Auth Providers

Bcrypt is a one-way function, and that single design fact is what turns an auth provider migration from a configuration exercise into an architectural decision. There is no decryption step available to a team moving users from one identity system to another. A new provider cannot ask the old one for the plaintext password, because the old one never had it either. All it ever had was a hash, and a hash, by construction, cannot run in reverse. The new system has to verify each login against whatever the old system produced, or the user loses access to the account.

The bcrypt format packs everything needed to check a password into one string: a version prefix, a cost factor, an embedded 22-character salt, and a 31-character hash, all concatenated together. That self-describing structure makes silent rehashing possible later: a target system can read the prefix, recognize the algorithm, and verify a login attempt against a hash it never generated. The same structure is also where naive migrations break. A mismatch in the version prefix or the cost factor causes verification to fail silently, with no error that points a team toward the actual cause.

Cost factor creep makes this worse in ways that are easy to miss during planning. Because hardware keeps getting faster, the cost factor needed to keep a bcrypt hash computationally expensive keeps rising, and an application that has been in production for years accumulates hashes generated under different cost factors at different points in its life. A bulk export from such a system is rarely uniform. One documented case involved a Ruby application where Auth0's import regex accepted cost-factor-10 hashes but rejected the cost-factor-12 hashes produced after a bcrypt gem upgrade; switching to Auth0's explicit [custom_password_hash](https://auth0.com/docs/manage-users/user-migration/bulk-user-import-schema) field with an algorithm specifier resolved the mismatch. Left unresolved, that kind of regex gap does not throw a loud error. It fails silently for whatever subset of users happens to carry the newer cost factor, and nothing in the system points a team toward the cause. The format that makes bcrypt hashes portable is the same format that makes them brittle: precise, legible, and unforgiving of any detail left unaccounted for.

The two strategies the constraint forces: verbatim import with silent rehash, or forced reset

Every bcrypt migration collapses into one of two approaches, because the one-way property of the hash leaves no third option. A team either imports the existing hashes as they are and rehashes them quietly the first time each user logs in, or it forces every user to set a new password at the moment of cutover.

The silent rehash approach is the preferred path for any userbase with a meaningful number of active logins, because it asks nothing of the user. The sequence runs like this: import the existing hash tagged with the algorithm and parameters that produced it, verify the user's next login attempt against that old hash, and once verification succeeds, immediately rehash the password with the target system's algorithm and cost factor, store the new hash, and mark that user's record as migrated. The user experiences nothing unusual. The migration effectively executes itself, one login at a time, for every account that happens to authenticate during the transition window. This self-executing behavior is what teams in the field call trickle migration, or just-in-time migration, and it does the bulk of the work without a single forced reset.

That approach only works if the team knows, for every single user record, which algorithm produced the hash, the complete parameter set that algorithm used, and where the salt lives, embedded in the hash string or stored separately, and missing any one of those three facts for even a fraction of the user base means the import arrives dead. Before committing to import, a verification pass against a sample of currently active users in the old system is the only reliable check: reconstruct the parameters, attempt a known-good login, and treat any failure in that sample as a signal that something in the parameter reconstruction is wrong before the bulk import goes anywhere near production.

Forced password reset is the fallback path, but it is not merely the strategy of last resort. The first is a source provider that does not export password hashes at all, which removes the choice entirely and leaves reset as the only route in. The second is the population of dormant or effectively churned accounts within an otherwise healthy migration: users who have not logged in for an extended window carry stale credentials with no active trigger for rehashing, and those stale credentials are a standing liability if the account data is ever exposed in a breach. Forcing a reset on that inactive tail after a defined holdout window does not represent a failure of the silent rehash strategy. It represents that strategy's natural completion, since a rehash can only happen on a login that never arrives for a dormant account.

Every auth provider migration reduces to one of two approaches: import the existing hashes verbatim and rehash on first login, or force all users to reset their passwords at cutover, since bcrypt hashes cannot be decrypted. Bulk-load the users and hashes wherever the source system permits export, keep a trickle path running in parallel to catch accounts that fail the bulk import or belong to federated identity connections that never had a portable hash to begin with, and reserve forced resets for the inactive tail once the holdout window closes.

What each major provider exports and locks away

The source provider's export policy, not the migrating team, usually decides the strategy. It's made by the source provider's export policy, and that policy varies sharply enough across systems that it tends to be the single most decisive factor in how a migration gets planned.

Auth0 supports the silent rehash path, but only through a deliberately gated process. Bcrypt password hashes can be exported from Auth0 and imported verbatim into most other bcrypt-based destinations. Password hashes are not included in the standard bulk user export by default, though. Getting them requires a support ticket, and that ticket option is not available on Auth0's free tier: hash export is restricted to paid-plan customers, at any paid subscription level, while free-plan users are locked out of the mechanism entirely and must escalate through support just to start. Beyond the hash itself, Auth0 user_id values carry connection prefixes that identify which identity connection created the account, and those prefixes need to be remapped through an old-to-new identifier table on the application side before the imported records mean anything to the new system.

AWS Cognito removes the choice altogether. Cognito does not disclose its internal hashing algorithm, and none of the hash, the salt, or the iteration count is accessible through any export mechanism the platform exposes. For teams migrating away from Cognito, this single fact determines the entire shape of the project before any other technical decision gets made.

Firebase Auth occupies a middle position that looks portable on the surface and is not. Firebase uses a customized variant of scrypt, with project-level parameters that include a signer key, a salt separator, a rounds value, and a memory cost figure, and every one of those parameters has to be carried over exactly or verification fails. The signer key and salt separator are constants set at the project level, and the absence of either one produces a silent verification failure that gives no indication of which parameter is missing. Firebase's scrypt variant is also not the same as the standard scrypt algorithm other systems expect, so a hash exported from Firebase cannot be dropped directly into a system built around standard scrypt.

Clerk's official migration tooling accepts hashed password values alongside an explicit algorithm specifier, and the list of algorithms it supports, including argon2i, argon2id, bcrypt, bcrypt_sha256_django, ldap_ssha, md5, md5_phpass, pbkdf2_sha256, pbkdf2_sha256_django, pbkdf2_sha1, phpass, scrypt_firebase, scrypt_werkzeug, and sha256, makes it one of the more accommodating import targets currently available.

Keycloak sits at the opposite end from Clerk. Keycloak has no native bcrypt support; since version 25.0.0 it hashes new passwords with Argon2 by default on non-FIPS deployments. Teams that need bcrypt compatibility in Keycloak are left with a narrow set of workarounds: force a password reset across the user base, write and maintain a custom SPI, temporarily store unhashed passwords during the migration window, or adopt a third-party plugin such as keycloak-bcrypt, which has stopped being consistently maintained. The demand for bcrypt compatibility in Keycloak comes overwhelmingly from teams migrating legacy databases built on Spring Security and Laravel, both of which default to bcrypt as their hashing algorithm.

Where verbatim bcrypt import fails after export

A successful export is not the same thing as a successful migration. Prefix mismatches, salts that live outside the hash string, and gaps in how the target system's library dispatches between algorithms form a second layer of failure that appears only once real users attempt to log in.

The Rails and Auth0 case from the earlier section illustrates the mechanism precisely, and the bulk import itself completed without error. The failure surfaced only afterward, when a specific subset of users, those whose hashes carried the newer cost factor, could not authenticate, because the receiving system's regex had been written to match only a portion of the valid bcrypt hash strings that existed in the source data. Nothing about the import process flagged this in advance. It appeared only in production, as a login failure with no obvious link back to cost factor.

Following the Auth0 migration guide, engineers imported user credentials with original [$2](https://github.com/better-auth/better-auth/issues/5016)b bcrypt hashes, but Better Auth did not verify them out of the box: login attempts returned the error "hex string expected got undefined," because the library defaulted to scrypt verification and had no automatic fallback to bcrypt. The documented workaround involves writing a custom emailAndPassword.password.verify function that inspects each hash's prefix and routes bcrypt-prefixed hashes to a bcrypt verifier. That fix, applied naively, introduces a second bug: it can fail to fall back to scrypt verification, which breaks login for any password created natively inside Better Auth after the migration, since those passwords use scrypt rather than bcrypt. The interoperability gap between the two algorithms remained a documented, unresolved issue as of the case's reporting. The lesson generalizes well past this one provider pair: any migration that mixes two hashing algorithms in one user table needs explicit, tested dispatch logic that handles both, not a patch that solves the first failure by introducing the second.

A third and quieter failure mode involves salts that are not stored anywhere in the hash string. Some homegrown authentication systems derive the salt at verification time from other user attributes instead of storing it, for instance by concatenating the account's email address with its creation date. A system built this way looks, from the outside, like a standard bcrypt implementation, because the hash string itself is well-formed. The derivation dependency only becomes visible when something as ordinary as an email address change silently breaks login for every user whose salt depended on that field, and the hash string itself gives no indication of what went wrong. Before any import, the safest posture is to confirm, row by row where necessary, which algorithm produced each hash, what its full parameter set is, and where its salt actually lives, including whether that salt is static or derived from data that can change. If any one of those three facts cannot be answered with confidence, the import should wait until it can be.

Zero-downtime cutover sequencing for a bcrypt hash migration

Diagram: Five-Phase Zero-Downtime Migration Sequence. Visualizes: Illustrate the five sequential phases of a bcrypt hash migration as a vertical or horizontal stepped flow.

A migration that avoids the failures described above follows a defined sequence, run in five phases, with both the old and new providers live and routable throughout.

Phase 1 is scoping and pre-flight. Query the full user table for every distinct hash prefix and cost factor present in the data before touching any migration infrastructure; a population with mixed cost factors requires the bulk import to handle each variant explicitly rather than assuming uniformity. Confirm with the source provider whether hash export requires a support ticket, which plan tier unlocks it, and how long that process takes, before committing to a bulk-first plan. Run a verification sample against a set of currently active users: reconstruct their hash parameters, attempt a known-good password against the stored hash in the source system, and treat any failure in that sample as a signal to find the cause before the migration proceeds any further.

Phase 2 is trickle, or just-in-time, migration for active users. On each login attempt, authenticate the user against the legacy provider first. On success, provision that user in the new provider using the imported hash, flip a migration flag on the record, and route every subsequent login for that user to the new system going forward. Because this happens transparently on a successful login, active users migrate themselves without noticing anything changed. For a provider like Cognito, where hashes are never exportable under any circumstance, trickle migration is the only clean route available. It is the only clean route available.

Phase 3 is bulk import for dormant users, wherever the source system's hashes are actually exportable. Export the full user corpus including hashes, and load it into the target system with each row explicitly tagged by algorithm identifier and cost factor. Handle prefix variants explicitly rather than assuming the entire export shares one prefix or one cost factor, since the earlier sections show how easily that assumption breaks. Verify a random sample from the freshly imported corpus against the old system's behavior before any production traffic is cut over to it.

Phase 4 is silent rehash on first post-migration login. When a bulk-imported user logs in for the first time after migration, verify the attempt against the imported hash, then immediately rehash the password using the target system's algorithm and cost factor, store that new hash, and retire the old one. Track migration progress broken out by algorithm and cost factor rather than a single migrated-or-not flag, since a binary flag will not reveal whether a particular cost-factor variant was missed somewhere in the process.

Phase 5 is holdout handling and the forced reset window. After a defined period, commonly around six months, any user who has not triggered a trickle login and was not swept up in the bulk import has simply not rehashed, and that population constitutes the dormant tail. Force password resets for that remaining group: stale credentials that have never triggered a rehash are a security liability on their own, and a reset at this stage is the defensible response rather than a sign that the migration failed. Communicate that reset requirement proactively and in language distinguishable from a phishing attempt, since a poorly worded reset email will suppress login activity and damage trust in the process.

Throughout all five phases, the legacy provider needs to stay live and reachable until the migration flag has flipped for every active-user segment, and it should not be decommissioned before the holdout window closes. Provider-specific extensibility logic, such as Auth0's Rules, Hooks, and Actions, does not carry over to another vendor automatically and has to be rebuilt in the destination system before cutover rather than treated as cleanup for later; Rules and Hooks are fully deprecated as of November 18, 2026, with Actions standing as Auth0's current extensibility model, and any of these should be treated as a blocking dependency on the project timeline.

Choosing a target algorithm and cost factor for the rehashed credentials

The algorithm and cost factor chosen for the rehash determines how long the new system stays defensible against the same hardware trends that made the old hashes cheaper to crack over time. Bcrypt's own cost factor illustrates the trend directly: a cost factor that produced a roughly 100-millisecond hash time in 2010 needed to rise to roughly factor 14 by 2025 to hold that same one-second target, as commodity hardware got faster at computing the function. A team rehashing credentials during a provider migration is, in effect, choosing the number that will need to rise again within a few years, and the choice should be made with that trajectory in mind rather than treated as a one-time setting.

For teams staying within the bcrypt family, selecting a cost factor higher than whatever the old system used, informed by the present-day compute cost of brute-forcing at that factor, is the straightforward move, and it keeps compatibility with every downstream system already built around bcrypt's fixed-length output. For teams willing to change algorithm entirely, Argon2, which Keycloak has hashed new passwords with by default on non-FIPS deployments since version 25.0.0 given its lack of native bcrypt support, offers memory-hard resistance to the kind of parallelized cracking hardware that has eroded bcrypt's margin over the past decade. Either path accomplishes the same underlying goal: converting a population of aging, mixed-cost-factor hashes into a uniform set of credentials hashed under parameters chosen for the hardware landscape that exists now, not the one that existed when the oldest account in the table first set its password.