← Blog

September 16, 2026 · Steve Johnson

Migrating Every Password Hash Without Logging Anyone Out

I led the backend migration of a live paying-customer platform off a legacy stack and onto a modern one, and the single hardest constraint wasn't performance or data volume — it was that tens of thousands of existing customers needed to keep signing in with their existing passwords, on the old identity system's exact hashing scheme, without ever being asked to reset anything or noticing a migration had happened at all.

Why you can't just copy the hashes over

Password hashes aren't portable in the way most data is. You can copy a hash from one system to another, but if the new system's identity provider doesn't support verifying against that specific hash format, the hash is useless — you have the number, but nothing that can check a plaintext password against it correctly. The legacy platform used two different hash formats depending on account age: an older format, and a newer wrapped scheme layering an HMAC step on top of bcrypt. Neither was natively supported by the new identity provider.

The naive fix — force a password reset for everyone — is a real, standard option, and it's a bad one for a platform with an active paying customer base. Mass forced resets are a support-ticket flood and a trust hit at exactly the moment you're trying to convince customers a migration was invisible.

Verify once, migrate silently, then forget the old hash existed

The approach that worked: on a customer's first login attempt after cutover, verify their password against the legacy hash format directly — handling both formats — using the identity system as a pass-through check rather than requiring it to natively understand the old format. If verification succeeds, immediately create the equivalent credential on the new identity provider using the plaintext the customer just typed in, which the new system hashes correctly on its own terms. Then null out the legacy hash field for that account, so the migration only ever exists in the past tense for any given user, at the moment they next log in.

Two things had to be true for this to be safe rather than merely convenient. First, it had to be race-safe — two concurrent login attempts, or a token failure mid-flight, could not be allowed to lock a customer out or leave an account in a half-migrated state, so the migration step itself needed the same care a payments team would give an idempotent charge. Second, accounts that never logged back in needed a separate hygiene job, expiring cold legacy hashes on a schedule, so the migration wasn't relying indefinitely on every customer eventually choosing to return.

What made this safe enough to ship

The real test wasn't "does this work for the common case." It was checking every account-state edge: a customer who'd already reset their password once under the old system, an account created the same week as cutover, an account with a stale or already-expired legacy session. None of that shows up if you only test the login flow with your own account, which works the first time almost by definition regardless of whether the migration logic is actually correct.

The lesson that generalizes: a "silent" migration isn't one where you hope nobody notices. It's one where you've specifically enumerated every state an existing record could be in, and verified the migration path handles each one correctly — because the customers who notice a migration are disproportionately the ones sitting in whatever edge case you didn't think to check.