Migrating from MD5/SHA to Bcrypt
Step-by-step guide to migrating password storage from MD5 or SHA-256 to bcrypt without forcing password resets. Covers the wrap-and-rehash pattern, database schema changes, and rollback strategies.
Detailed Explanation
Migrating from MD5/SHA to Bcrypt
Migrating from a weak hashing algorithm (MD5, SHA-1, SHA-256) to bcrypt is one of the most impactful security improvements you can make. The good news: you can do it without forcing all users to reset their passwords.
The Challenge
You cannot convert an existing MD5/SHA hash into a bcrypt hash because hash functions are one-way. You need the original password, which you do not have. The solution is a two-phase migration that handles legacy hashes and upgrades them when users log in.
Phase 1: Wrap Existing Hashes
Immediately bcrypt-hash all existing MD5/SHA hashes:
-- Before: passwords table
-- password_hash: MD5 hex string (32 chars)
-- After: add columns
ALTER TABLE users ADD COLUMN hash_type VARCHAR(10) DEFAULT 'md5';
ALTER TABLE users ADD COLUMN bcrypt_hash VARCHAR(60);
import bcrypt
# For each user
bcrypt_wrapped = bcrypt.hashpw(
existing_md5_hash.encode('utf-8'),
bcrypt.gensalt(12)
)
# Store bcrypt_wrapped, set hash_type = 'bcrypt_md5'
This wrapping step can be done as a batch job. It immediately protects all accounts with bcrypt’s slow hashing, even before users log in again.
Verification for Wrapped Hashes
When verifying a "bcrypt_md5" hash:
def verify_password(password, stored_hash, hash_type):
if hash_type == 'bcrypt_md5':
# First MD5 the password, then compare with bcrypt
md5_hash = hashlib.md5(password.encode()).hexdigest()
return bcrypt.checkpw(md5_hash.encode(), stored_hash.encode())
elif hash_type == 'bcrypt':
return bcrypt.checkpw(password.encode(), stored_hash.encode())
Phase 2: Rehash on Login
When a user successfully logs in, upgrade their hash to pure bcrypt:
def login(email, password):
user = db.find_user(email)
if not verify_password(password, user.hash, user.hash_type):
return False
# Upgrade to pure bcrypt if still using wrapped hash
if user.hash_type != 'bcrypt':
new_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt(12))
db.update_user(user.id, hash=new_hash, hash_type='bcrypt')
return True
Over time, active users migrate to pure bcrypt. Inactive accounts remain protected by the wrapped hash from Phase 1.
Migration Timeline
| Phase | When | Accounts Protected |
|---|---|---|
| Phase 1 (wrap) | Day 1 | 100% (wrapped) |
| Phase 2 (rehash) | Ongoing | Increases over time |
| After 6 months | ~80-90% pure bcrypt | |
| After 12 months | ~95% pure bcrypt |
Cleanup
After a sufficient period (6–12 months), force remaining users to reset their passwords. These are likely inactive accounts. Remove the legacy hash columns and hash_type field once all accounts are migrated.
Rollback Strategy
Keep the original hash column intact during migration. If issues arise, you can revert to the original verification logic without data loss. Only drop the legacy columns after the migration is confirmed successful.
Use Case
Password hash migration is a common security remediation task, often triggered by a security audit, penetration test finding, or compliance requirement. The wrap-and-rehash pattern allows teams to improve security immediately without the disruptive and user-unfriendly step of forcing all users to reset passwords. This strategy is applicable to any migration from a fast hash to a slow hash, not just MD5-to-bcrypt.