· 3 min read · 638 words
Moving From Bcrypt to Argon2 Without a Password Reset
Three ways to change password hashing function on a live database, what each one costs, and why forcing every user to reset is the option to avoid.
- bcrypt
- argon2
- migration
You cannot convert a bcrypt hash into an Argon2 hash. The plaintext is gone, and that is the point of the exercise. What you can do is arrange for the new hashes to appear over time, or wrap the old ones so they are protected by the new function immediately.
Option 1: rehash on login
The simplest approach. When somebody logs in successfully you have their plaintext for the length of that request, so you verify against the old hash and then write a new one.
const stored = user.passwordHash;
const isLegacy = stored.startsWith('$2');
const ok = isLegacy
? await bcrypt.compare(password, stored)
: await argon2.verify(stored, password);
if (!ok) return fail();
if (isLegacy) {
user.passwordHash = await argon2.hash(password, ARGON2_PARAMS);
await user.save();
}Store the full encoded hash, including its parameters, and dispatch on the prefix. Both formats carry everything verification needs, so you do not need a separate column to remember which is which.
The cost of this approach is time. Accounts that never log in keep their bcrypt hash forever, and you cannot remove the old code path until you decide to give up on them. Six months in you will still have a long tail.
Option 2: wrap the old hash
If the reason for migrating is that the current hashes are genuinely weak, waiting for logins is not good enough. Instead, run the new function over the existing hash and store that.
new_value = argon2id(bcrypt_hash_string)You can do this in a batch job with no user involvement, because the bcrypt hash is already in the database. Mark the wrapped rows so verification knows to bcrypt the candidate password first and then check the result against the Argon2 value.
if (user.hashScheme === 'argon2-over-bcrypt') {
// Reproduce the inner hash using the salt already inside it.
const inner = await bcrypt.hash(password, user.legacyBcryptSalt);
return argon2.verify(user.passwordHash, inner);
}Every account is protected by Argon2 the moment the batch finishes. The price is a two layer scheme you now have to carry, so combine it with option 1 and unwrap each account as its owner logs in.
Option 3: force a reset
This is the option that looks decisive and behaves badly. A forced reset across your whole user base sends a large volume of password emails at once, which trains people to click password links they did not ask for, generates a support load, and loses you the accounts of everyone who cannot be bothered.
It is the right call after a confirmed breach, when the hashes themselves are suspect. It is the wrong call for a planned upgrade.
Before you start
- 1. Widen the password column. An encoded Argon2id hash runs to around 100 characters, well past the 60 that bcrypt needs. A VARCHAR(60) column will truncate silently and every affected account is locked out.
- 2. Deploy verification for both formats first, and let it run for a while before anything starts writing the new one. If you ship both changes together and the new path has a bug, you find out during a login storm.
- 3. Measure Argon2 on production hardware. Its memory parameter means concurrency behaves differently from bcrypt. Fifty simultaneous logins at 47 MiB each is over 2 GB of resident memory that has to come from somewhere.
- 4. Keep a counter of how many accounts remain on the old scheme, and put it on a dashboard. Otherwise you will not know when the migration is finished, and the legacy branch stays in the codebase for years.
Is it worth it?
Moving from bcrypt at cost 12 to Argon2id is a modest improvement for a real amount of engineering time. Moving from unsalted MD5 to anything is urgent. Be honest about which of those you are doing, because they justify very different amounts of effort.
The comparison of the four functions goes into what you actually gain from each move.