Learning how to migrate bcrypt to Argon2id safely is less about changing the algorithm that creates new password hashes and more about handling the hashes already sitting in your database. Get that transition wrong, and a clean security upgrade can turn into a surprisingly efficient user lockout system. If you replace bcrypt verification with Argon2id and deploy the change, every existing bcrypt password suddenly looks wrong. You also cannot run a database script that converts those hashes into Argon2id hashes, because the original passwords are gone by design.
That is password hashing doing exactly what it is supposed to do, even if it makes migration awkward. The safe way to migrate bcrypt to Argon2id is to support both formats temporarily, verify users with the algorithm that created their stored hash, and replace valid bcrypt hashes with fresh Argon2id hashes after successful logins. Active users migrate without changing their passwords, while dormant users can be handled later with a normal reset policy.
This article starts where Foriloop’s Argon2 password hashing guide ends. If you need a refresher on salts, cost factors, or why bcrypt is still a respectable legacy format, read the bcrypt hashing guide first. The focus here is the messy part: keeping authentication working while two password formats temporarily exist at the same time.
You Cannot Convert a bcrypt Hash Into an Argon2id Hash
A password hash is not encrypted text waiting for the right key. It is a one-way verifier built from the password, a salt, and the hashing algorithm’s work settings. Your application can test whether a submitted password matches a stored bcrypt hash, but it cannot recover the original password.
That rules out the tempting bulk migration:
bcrypt hash -> database script -> Argon2id hashThere is no direct conversion step in the middle. You could wrap an existing bcrypt hash inside another password-hashing scheme, but that would not give you Argon2id(password). OWASP documents layered hashes as one possible temporary migration technique for legacy credentials, but they still depend on the older hash and require matching verification logic until the user eventually authenticates and the application can store a direct hash of the password. For a normal bcrypt-to-Argon2id migration, rehash-on-login is usually the cleaner path.
The useful moment happens when the user logs in. At that point, the application briefly has access to the submitted password and can verify it against the existing bcrypt hash. If verification succeeds, it can hash that same password with Argon2id and replace the old stored value. The plaintext password should stay temporary, so never save it to a migration table, log it, or send it through a background queue.
Migrate bcrypt to Argon2id With Rehash-on-Login
The usual pattern is called opportunistic rehashing, or rehash-on-login. Instead of trying to convert every database row at once, your authentication code temporarily understands both bcrypt and Argon2id. The application verifies each stored credential with the matching algorithm, then upgrades legacy hashes only after the user proves they know the password.
The login flow becomes:
- Load the user’s stored password hash.
- Identify which supported format created it.
- Verify the submitted password with the matching verifier.
- If verification fails, return the normal generic invalid-credentials response.
- If bcrypt verification succeeds, hash the password with Argon2id and replace the old value.
- If Argon2id verification succeeds, optionally check whether its settings need to be upgraded.
New registrations, password changes, and password resets should start writing Argon2id once your application is ready for it. Otherwise, you are creating new bcrypt credentials while simultaneously trying to get rid of bcrypt.
You may not need a separate hash_type column because standard encoded password hashes often identify their own format. Common bcrypt strings use prefixes such as $2a$, $2b$, or $2y$, while encoded Argon2id values begin with $argon2id$ and include their work parameters. NIST password guidance recommends retaining enough information about the password hashing scheme and cost to support verification and future migration, and self-describing formats make that easier.
A separate scheme column can still make sense if your application already uses custom credential metadata. If you use one, update the scheme and hash together. A row that says argon2id beside a bcrypt hash is not a cosmetic mistake; it is probably a locked account.
Prepare Storage Before You Migrate bcrypt to Argon2id
bcrypt hashes are commonly stored in fields sized around their standard 60-character encoded representation. Argon2id encoded strings are longer because they include the algorithm name, version, memory cost, time cost, parallelism, salt, and resulting hash. That means a field designed specifically for bcrypt may no longer be large enough.
If you migrate bcrypt to Argon2id while keeping a VARCHAR(60) column, that field cannot hold a typical encoded Argon2id hash. Depending on your database engine and configuration, the write may fail or the value may be truncated. Expand the field before enabling Argon2id writes, then test an actual write-and-read round trip against the same database setup you use in production.
A generously sized string field such as VARCHAR(255) or a suitable text field avoids tying your schema to one algorithm. The PHP password_hash documentation similarly recommends leaving enough room for password hashes to grow as algorithms change. Store the encoded hash exactly as your password library returns it, and do not trim it, normalize it, change its case, or compare password hashes manually.
Make Every Login Path Understand Argon2id First
There is another compatibility rule that matters just as much as the database column: readers must arrive before writers. Every part of your application that verifies passwords should understand Argon2id before any part starts storing Argon2id hashes.
That can include web application nodes, API services, admin interfaces, older app versions still receiving traffic, and other authentication entry points. Imagine a rolling deployment where one upgraded server logs a user in and converts their bcrypt hash to Argon2id. Their next request lands on an older server that only understands bcrypt, and from the user’s point of view, their correct password suddenly stopped working.
A safer deployment happens in stages. First, deploy code everywhere that can verify both bcrypt and Argon2id while still writing bcrypt. Once that version is running everywhere, switch new password writes to Argon2id and enable rehash-on-login.
That extra deployment is boring, which is exactly what you want from an authentication migration.
A Small Python Verify-and-Rehash Example
The following example uses bcrypt for legacy verification and PasswordHasher from argon2-cffi for Argon2id. It assumes the database stores standard encoded hashes. A real authentication system still needs rate limiting, secure session handling, account checks, generic public errors, and whatever protections your framework already provides.
from dataclasses import dataclass
import bcrypt
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerificationError
argon2_hasher = PasswordHasher()
@dataclass(frozen=True)
class PasswordCheck:
authenticated: bool
replacement_hash: str | None = None
def verify_and_prepare_upgrade(
password: str,
stored_hash: str,
) -> PasswordCheck:
if stored_hash.startswith("$argon2id$"):
try:
argon2_hasher.verify(stored_hash, password)
needs_rehash = argon2_hasher.check_needs_rehash(stored_hash)
except (InvalidHashError, VerificationError):
return PasswordCheck(authenticated=False)
replacement = argon2_hasher.hash(password) if needs_rehash else None
return PasswordCheck(
authenticated=True,
replacement_hash=replacement,
)
if stored_hash.startswith(("$2a$", "$2b$", "$2y$")):
try:
matches = bcrypt.checkpw(
password.encode("utf-8"),
stored_hash.encode("ascii"),
)
except (UnicodeEncodeError, ValueError):
return PasswordCheck(authenticated=False)
if not matches:
return PasswordCheck(authenticated=False)
return PasswordCheck(
authenticated=True,
replacement_hash=argon2_hasher.hash(password),
)
return PasswordCheck(authenticated=False)
Two details matter more than the amount of code. First, the algorithm choice comes from stored server-side credential data, not from a request parameter. Second, a failed Argon2id verification does not fall back to bcrypt. If the stored value says it is Argon2id and verification fails, authentication fails rather than trying unrelated algorithms afterward.
PasswordHasher() uses Argon2id and handles salt generation for you. The current argon2-cffi documentation also recommends checking whether a successfully verified hash needs rehashing so future parameter upgrades can use the same mechanism.
One warning: do not copy this prefix logic blindly into a framework that stores passwords in its own format. Django and Spring Security, for example, may wrap bcrypt hashes in framework-specific identifiers or preprocessing rules. If your framework already has a password-hasher registry, use it.
Django can automatically upgrade supported password hashes after successful authentication when its hasher configuration is set correctly. See the Django password documentation for the framework-specific behavior. PHP provides similar building blocks through password_verify() and password_needs_rehash(). Frameworks already know their own password formats better than your custom parser does.
Do Not Overwrite a Newer Password by Accident
After verification succeeds and you create a replacement Argon2id hash, the obvious database update looks like this:
UPDATE users
SET password_hash = :replacement_hash
WHERE id = :user_id;
That seems harmless until another password operation happens at the same time. A login request might read the old bcrypt hash and verify it successfully, then the user completes a password reset in another request before the migration update is saved. If the original login blindly writes its Argon2id version afterward, it can overwrite the user’s brand-new password with a hash of the old one.
A safer approach is a compare-and-swap update:
UPDATE users
SET password_hash = :replacement_hash
WHERE id = :user_id
AND password_hash = :verified_hash;
Now the replacement happens only if the database still contains the exact hash that was originally verified. If zero rows are updated, something else changed the credential first, so do not blindly retry the overwrite. Reload the current state and handle authentication based on that newer credential.
This is one of those details that sounds overly cautious until two requests happen at exactly the wrong time. Then it becomes the whole problem.
Rehashing Is Not a Password Change
An automatic hash upgrade changes the representation of the user’s password. It does not mean the user chose a new password, and your application should not casually treat those two events as the same thing.
For example, you probably do not want opportunistic rehashing to update password-change history, send a “your password was changed” email, trigger account-recovery workflows, or revoke every active session. Those actions may make sense after a real password change, but a bcrypt-to-Argon2id migration is only changing how the same password is stored.
Be especially careful if your application invalidates sessions based directly on changes to the stored password hash. Changing bcrypt to Argon2id changes that value even though the actual password stayed the same. If real password changes should revoke sessions, use an explicit credential or session version instead of treating the encoded hash itself as business state.
The Order Matters When You Migrate bcrypt to Argon2id
A safe rollout does not need twenty stages, but it should have a clear order. The goal is to make every part of the system understand the new format before any part starts creating it.
A practical sequence to migrate bcrypt to Argon2id safely is:
- Inventory your existing password formats. Confirm bcrypt prefixes, cost factors, any peppers or preprocessing, and every place passwords can be written or verified.
- Prepare the database and runtime. Expand the hash column and make sure the Argon2 library works in every deployment environment.
- Deploy dual verification first. Make every login path understand bcrypt and Argon2id while bcrypt remains the write format.
- Switch new writes to Argon2id. New users, password resets, and password changes now use Argon2id.
- Enable rehash-on-login. Start upgrading successful bcrypt logins and monitor authentication failures, memory pressure, and migration errors.
- Deal with dormant accounts later. Define when remaining bcrypt users must reset their passwords.
There is also one important rollback rule: once you store even one Argon2id credential, you cannot safely roll back to code that only understands bcrypt. A rollback can stop new Argon2id writes or disable automatic upgrades, but it still has to verify Argon2id credentials that already exist.
Backups deserve the same thought. Restoring an older database may reintroduce bcrypt credentials even after the live system appears fully migrated. Do not delete legacy verification until your recovery procedures no longer need it.
The Biggest Migration Traps
You do not need to memorize every theoretical edge case, but several problems are worth testing explicitly because they can lock out real users.
The Database Cannot Store the New Hash
Test an actual Argon2id hash through your real production database and ORM configuration. Do not assume a local SQLite test proves your production schema is safe. The whole point is to confirm that the complete encoded string survives the same storage path your real application uses.
The Old Verifier Disappears Too Early
Dormant users, restored backups, old admin tools, or rarely used login paths can keep bcrypt around longer than expected. Remove legacy support based on measured credential state, not because a few months passed and the main login screen appears clean.
The Old Password Pipeline Had Hidden Rules
Legacy systems sometimes trim whitespace, normalize Unicode, add a pepper, pre-hash inputs, or handle bcrypt’s password-length limitation in a specific way. Your legacy verifier needs to reproduce the behavior that created the old hash, otherwise valid users can fail verification before you ever reach the Argon2id step.
This matters especially around bcrypt’s well-known 72-byte boundary. Historical handling of long passwords can create ambiguity because different submitted values may effectively authenticate against the same legacy credential. For affected accounts, a targeted password reset can be safer than silently deciding that whichever version logs in first becomes the new Argon2id password.
The Python bcrypt 5.0 release also changed hashpw() behavior for new hashes: inputs longer than 72 bytes now raise ValueError instead of being silently truncated as they were in earlier releases. That is exactly the kind of library-version detail worth testing before migration.
Argon2 Is Tested Without Real Concurrency
Argon2id is intentionally memory-hard. If each operation is configured to use 64 MiB, large bursts of overlapping password operations can consume several gigabytes of memory before the rest of the application is counted.
Real usage depends on your library and environment, so benchmark production-like concurrency instead of testing one login on your laptop and assuming the server will behave the same way. Successful legacy logins are temporarily more expensive too, because they perform bcrypt verification followed by Argon2id hashing.
Unknown Hash Formats Fall Through to a Guess
Unknown or malformed credential formats should fail closed. Return the same generic public login error you normally use and record a safe internal warning or metric so the problem can be investigated.
Do not try every password algorithm “just in case,” and definitely do not let the client tell the server which hashing algorithm to attempt. Authentication becomes much easier to reason about when one stored credential maps to one expected verification path.
Dormant Accounts Will Not Migrate Themselves
Rehash-on-login works beautifully for active users because they eventually supply the password needed to create a new Argon2id hash. Dormant users never do, and that is not a bug. It is the tradeoff that lets you migrate without forcing every account through a password reset immediately.
For a correctly configured bcrypt deployment with no evidence of compromise, a mixed period can be reasonable. The OWASP Password Storage Cheat Sheet discusses upgrading hashes when users authenticate and separately deciding how older credentials should eventually be handled.
A practical policy is to let active accounts migrate naturally, then require a password reset for any bcrypt credentials still remaining after a defined inactivity period. That avoids disrupting everyone at once while still giving the migration a clear end.
A known breach is different. If a password database or pepper has been compromised, opportunistic rehashing is not an incident-response plan because old hashes may still exist in stolen copies even after you upgrade the live database. That situation may require password resets, session revocation, secret rotation, and the rest of your incident-response process.
Quietly waiting for users to log in does not make stolen password hashes disappear.
Test the Transition, Not Just the Libraries
When you migrate bcrypt to Argon2id, the dangerous part is not whether bcrypt.checkpw() or PasswordHasher.verify() works in isolation. The real risk is whether an account moves safely from one credential state to another while resets, concurrent logins, database writes, and multiple authentication paths are happening around it.
At minimum, test that:
- A correct bcrypt password authenticates and becomes a valid Argon2id hash.
- The same password works again after migration.
- A wrong bcrypt password never changes the database.
- Current Argon2id hashes do not get rewritten unnecessarily.
- Old Argon2id parameters can be upgraded after successful verification.
- Unknown formats fail closed.
- The database preserves the complete Argon2id string.
- A concurrent password reset cannot be overwritten by a migration.
- Every password-writing path uses Argon2id after the switch.
- Rehashing does not trigger password-change side effects unless intended.
- Production-like load does not create unacceptable memory pressure.
Use realistic Argon2id settings for integration and load testing. Cheap settings are fine for a fast unit test suite, but they tell you nothing about actual memory usage or login latency. Security configuration is still part of your application’s behavior, even when it lives in an environment variable.
Remove bcrypt Only When It Is Actually Gone
Track migration progress using aggregate metrics such as the number of bcrypt and Argon2id credentials, successful upgrades, upgrade failures, authentication error rates, verification latency, and memory pressure. Do not log passwords or full password hashes just to make migration dashboards easier.
When the live bcrypt count reaches zero, check everything that could bring old credentials back, including dormant-account recovery, database backups, rollback builds, disaster-recovery procedures, and forgotten authentication services. Only then should you consider removing bcrypt support.
Keep the broader upgrade pattern, though:
verify -> check parameters -> rehash after successful authenticationArgon2id settings will change over time too. Hardware improves, libraries evolve, and recommended work factors increase, so algorithm agility is worth keeping even after bcrypt is gone.
If you need to migrate bcrypt to Argon2id, the safest migration is not a clever conversion script. It is a deliberately compatible rollout: read the old and new formats, write only the new one, upgrade after proving the password, protect database updates from races, and retire the legacy path only when the evidence says it is actually gone.
That is less dramatic than forcing every user to reset their password at once, and it is also much less likely to turn a security improvement into your next support emergency.

