August 14, 2026 · Steve Johnson
The Password Migration Nobody Had to Notice
Every password migration guide assumes you get to force a reset. Send an email, invalidate every session, done — the new system starts clean. That option wasn't on the table for a professional-training company migrating a large legacy learning platform: the userbase was paying customers mid-subscription, and a mass reset email is itself a support-ticket generator and a trust hit. The migration had to be invisible. A customer should be able to type the password they've used for years into the new system and just get in — with no idea anything changed underneath them.
That constraint is what shaped the whole approach, and it's worth walking through because "zero-friction credential migration" sounds simple until you hit the actual mechanics of it.
The hash format problem
The legacy platform had accumulated two different password hash formats over its life: the older phpass scheme, and a newer wrapped-bcrypt variant — base64-encoded HMAC-SHA384 layered over bcrypt. Any migration path had to handle both, because there was no way to know in advance which format a given user's row was in without checking.
The approach: verify-then-migrate, on login, lazily. When a user attempts to sign in, the system checks the incoming password against whichever legacy hash format that row actually has. If it matches, the password is mirrored into the new identity provider using its native hashing, and the legacy hash is nulled out. The user never sees an intermediate state — from their side, they typed a password and got in. From the system's side, that single login event is also the migration event.
Lazy migration means the "migrated" population grows organically as real logins happen, rather than requiring a bulk pre-migration pass across every row up front. It also means inactive accounts that never log in again simply never migrate — which is fine, since there's no user-facing harm in a hash that's never checked again.
Where it gets hard: failure modes
The interesting engineering isn't the happy path, it's what happens when something goes wrong mid-migration.
A token failure can't lock a user out. If the call to write the new hash into the identity provider fails — network blip, rate limit, whatever — the login has to still succeed against the legacy hash, and the migration attempt has to be retryable on the next login rather than leaving the account in a broken half-migrated state. The legacy hash only gets nulled after the new one is confirmed written, not before.
Email-match repointing. Some accounts needed to be repointed by email match rather than by ID — and when that happens, legacy fields that the rest of the platform still reads have to survive the repoint, not get dropped because the new record technically "replaces" the old one.
Cold hashes need an expiry path. Accounts that never log in again keep their legacy hash sitting in the database indefinitely, which is its own liability — an old, weaker hash format is a lingering attack surface even if nobody's actively using the account. A hygiene job expires hashes past a threshold, closing that off without requiring every user to have logged in.
What made it work
None of these are exotic techniques individually. Verify-then-migrate-on-login is a known pattern. What made this instance robust was refusing to treat any of the edge cases as acceptable to skip: race-safety on the token write, field preservation on repoint, and hash expiry for the accounts that never come back. Skip any one of those and the migration either locks someone out or leaves a quiet security gap that nobody notices until it's audited.
The result was a cutover with no forced resets, no mass email, and no visible event for the customer at all — which, for a live paying userbase, is the actual definition of success. The best migration is the one nobody notices happened.