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.

Best Practices

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.

Try It — Bcrypt Generator

Open full tool