User Migration – A Technical Challenge We Solve Without Access Loss
Our user migration service specializes in password migration using lazy migration and ETL techniques, ensuring no password loss. When a business decides to move to a new CMS or framework, the most delicate issue is users. Their passwords are encrypted with the old algorithm—sha512 with salt (Drupal 7), phpass (WordPress), or md5($password.$salt) from a custom CRM. The new system doesn't understand these hashes, and simply copying the database won't work—nobody will be able to log in. Worse, engineers often make the mistake of copying the user table as-is, only to find that all passwords are invalid. The result is a mass session reset and loss of loyalty. We've carried out over 50 migrations, including projects with 200,000 users, and know how to transfer users so they don't even notice the move. Below is a proven strategy and the tools (Python, Golang, Laravel, Django) we use on every project. Our services start at $2,000 for databases up to 100,000 users, with typical savings of 30-50% compared to full system rebuilds. For a typical project with 100k users, the cost is $2,000 and average savings are $3,000-$5,000. We guarantee that no user will lose access.
Problems We Solve
- Hashing algorithm incompatibility: the old system uses
sha512orMD5, the new one onlybcrypt. - Duplicate users by email when merging databases.
- Loss of roles and access rights during transfer.
- Need to preserve active sessions and avoid mass re-login.
- SSO integration for temporary co-existence of old and new platforms.
Why Lazy Migration Is Better Than Full Rehashing
Lazy migration allows user transfer without downtime or access loss. Old hashes remain in the database, and on each login the algorithm is checked; on success, the password is rehashed with the new one. This is 3–5 times faster than a full forced reset and doesn't cause user churn. Moreover, it enables gradual migration without downtime—you can migrate the database in the background while 100% load remains on the old system. 90% of users don't even notice the migration process. Lazy migration preserves salting and key stretching properties inherent in algorithms like bcrypt and PBKDF2, and it avoids rainbow table attacks by leveraging bcrypt's adaptive cost factor.
| Strategy | Implementation Time | User Impact | Security |
|---|---|---|---|
| Lazy migration | 1-2 days | Minimal, transparent process | High with correct implementation |
| Forced reset | 1-3 days | Requires user password change | High |
| Full rehashing | 3-7 days | Medium, possible temporary unavailability | Medium (errors during mass rehashing) |
Forced Reset for Legacy Algorithms
If supporting outdated algorithms (MD5, SHA1) is undesirable, we send users an email with a reset token. This is safer than storing weak hashes. Scenario: a script finds all users with algorithm legacy_md5, generates a one-time token, and sends an email. Token validity is 7 days. After a successful change, the password is saved with bcrypt.
When the New System Does Not Support the Old Algorithm
In this case, we implement an intermediate verification layer. For example, for phpass (WordPress) we use a custom Python implementation. "PHPass is a portable public domain password hashing framework"—its algorithm is supported via a custom function. Similarly for PBKDF2 (Django) and sha512 (Drupal 7). This preserves compatibility without rewriting the entire system.
How to Implement Lazy Migration
Step-by-step plan:
- Analyze existing hashes and identify algorithms.
- Implement a verification function for each algorithm.
- Add rehashing logic on successful login.
- Develop ETL script for data transfer.
- Test on a copy of the database.
- Run migration and monitor errors.
The main idea is to store the original hash and algorithm label in the password_algorithm field. On login, call the verify_password function, which checks the algorithm, and on success—upgrade_password_hash. Let's look at the Python implementation.
# models/user.py
class User(BaseModel):
password_hash: str
password_algorithm: str # 'bcrypt', 'phpass', 'sha512', 'legacy_md5'
def verify_password(self, plain_password: str) -> bool:
if self.password_algorithm == 'bcrypt':
return bcrypt.checkpw(plain_password.encode(), self.password_hash.encode())
elif self.password_algorithm == 'phpass':
return phpass_check(plain_password, self.password_hash)
elif self.password_algorithm == 'legacy_md5':
return hashlib.md5(plain_password.encode()).hexdigest() == self.password_hash
elif self.password_algorithm == 'pbkdf2_sha256':
return django_pbkdf2_check(plain_password, self.password_hash)
return False
def upgrade_password_hash(self, plain_password: str):
new_hash = bcrypt.hashpw(plain_password.encode(), bcrypt.gensalt(rounds=12))
self.password_hash = new_hash.decode()
self.password_algorithm = 'bcrypt'
db.save(self)
PHPass Compatibility Check (WordPress)
Implementation details for WordPress
WordPress uses [phpass](https://en.wikipedia.org/wiki/PHPass). For integration with Python:import hashlib
ITOA64 = './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
def phpass_check(password: str, stored_hash: str) -> bool:
if stored_hash.startswith('$P$') or stored_hash.startswith('$H$'):
return _phpass_verify(password, stored_hash)
return hashlib.md5(password.encode()).hexdigest() == stored_hash
def _phpass_verify(password: str, hash_str: str) -> bool:
count_log2 = ITOA64.index(hash_str[3])
count = 1 << count_log2
salt = hash_str[4:12]
hash_val = hashlib.md5((salt + password).encode()).digest()
for _ in range(count):
hash_val = hashlib.md5(hash_val + password.encode()).digest()
output = _encode64(hash_val, 16)
return hash_str[12:34] == output[:22]
ETL: User Transfer with Role Mapping
For bulk transfer, we use an ETL script. Example for WordPress:
def migrate_users_from_wordpress(wp_db, new_db):
cursor = wp_db.cursor(dictionary=True)
cursor.execute("""
SELECT u.ID, u.user_login, u.user_pass, u.user_email,
u.user_registered, u.display_name,
um.meta_value as first_name, um2.meta_value as last_name
FROM wp_users u
LEFT JOIN wp_usermeta um ON u.ID = um.user_id AND um.meta_key = 'first_name'
LEFT JOIN wp_usermeta um2 ON u.ID = um2.user_id AND um2.meta_key = 'last_name'
ORDER BY u.ID
""")
migrated = 0
skipped = 0
for wp_user in cursor.fetchall():
existing = new_db.get_user_by_email(wp_user['user_email'])
if existing:
skipped += 1
continue
algorithm = detect_wp_hash_algorithm(wp_user['user_pass'])
new_db.create_user({
'username': wp_user['user_login'],
'email': wp_user['user_email'],
'password_hash': wp_user['user_pass'],
'password_algorithm': algorithm,
'display_name': wp_user['display_name'],
'created_at': wp_user['user_registered'],
'legacy_id': wp_user['ID'],
})
# Role mapping: from wp_usermeta meta_key = 'wp_capabilities'
roles = get_user_roles(wp_db, wp_user['ID'])
new_db.assign_roles(wp_user['user_email'], roles)
migrated += 1
print(f"Migrated: {migrated}, Skipped: {skipped}")
Handling Login and Rehashing
def login(email: str, password: str):
user = db.get_user_by_email(email)
if not user:
return None
if user.verify_password(password):
if user.password_algorithm != 'bcrypt':
user.upgrade_password_hash(password)
return create_session(user)
return None
What's Included in User Migration Work
- Audit of current user database and hashing algorithms.
- Development of ETL scripts with role mapping and additional fields.
- Implementation of lazy migration (or forced reset) with testing on a copy.
- Monitoring setup after deployment (24-hour observation).
- Rollback procedure documentation and backup.
Timelines and Common Mistakes
Estimated Timelines
- For databases up to 100,000 users: 2–3 working days for audit, development, and testing.
- For databases from 100,000 to 500,000 users: 5–7 days including ETL and load testing.
- Large projects with dozens of roles and custom fields may require more time.
Common Migration Mistakes
- Missing duplicate email checks—leads to conflicts and data loss.
- Ignoring role mapping—users are transferred but lose access rights.
- Attempting to rehash all passwords at once—causes CPU load and errors.
- Lack of rollback plan—no quick recovery on failure.
- Not closing old sessions—users remain in the system with old data.
Tips:
- Always test on a copy of the database.
- Use transactions and scripts with rollback.
- Set up error monitoring after deployment (first 24 hours are critical).
- Keep a backup of the old database and a reverse migration script.
Hashing Algorithm Comparison
| Algorithm | Hash Length | Brute Force Resistance | Standard in CMS |
|---|---|---|---|
| bcrypt | 60 characters | High (cost adjustable) | Laravel, Symfony, Rails |
| phpass | 34 characters | Medium (MD5-based) | WordPress, Drupal 7 |
| PBKDF2 | 98 characters | High | Django, Python |
| MD5 | 32 characters | Low | Legacy systems |
Order an audit of your project—first day free. Contact us for a consultation and get an accurate estimate with a guaranteed result.







