Skip to main content
SEMastery
Data Accessintermediate

Zero-Downtime Migrations: A Practical Demo with Password Hashing

Learn zero-downtime migrations with a real password hashing demo in ASP.NET Core. Upgrade old hashes safely as users log in, with diagrams and code.

12 min readUpdated March 7, 2026

The slow bridge repair

Imagine a busy bridge in your city. Thousands of people cross it every day on their way to work and school. The city wants to replace the old wooden planks with strong new steel ones. But they cannot close the bridge — too many people depend on it.

So what do they do? They build the new lane next to the old one. For a while, both lanes work. Cars can use either. Then, slowly, they guide traffic onto the new steel lane. Once almost everyone is using the new lane, they quietly remove the old wooden one. Nobody ever had to stop. Nobody got stuck.

This is exactly how a zero-downtime migration works. You do not switch off your app to change things. You let the old way and the new way live together for a while, move everyone over gently, and only then remove the old way.

In this article we will use one very real example: upgrading how you store passwords. It is a perfect demo because passwords have a special rule that forces you to migrate slowly. Let us see why.

Why passwords force a slow migration

A password is never stored as plain text. That would be dangerous. Instead, we store a hash — a scrambled fingerprint of the password. Hashing is a one-way street. You can turn a password into a hash, but you can never turn a hash back into the password.

This matters a lot. Say your old system used a weak hashing method, and you want to switch to a stronger one. You cannot just re-scramble all the old hashes. You do not have the real passwords — only the old scrambled versions.

Figure 1: Hashing only works one way. You can go from password to hash, but never back. This is why you cannot bulk-upgrade hashes without the real password.

So when can you get the real password? Only at one moment: when the user types it during login. At that instant, you can check it against the old hash, and if it is correct, you can quietly create a brand-new stronger hash and save it. The user never notices.

This means the upgrade has to happen one user at a time, as they log in. That is a rolling migration. And a rolling migration is, by its nature, a zero-downtime migration.

The expand-contract pattern

The safe way to do any zero-downtime change has a name: the expand-contract pattern (some people call it parallel change). It splits a scary change into three calm steps.

PhaseWhat you doIs the old format still allowed?
ExpandTeach the app to understand BOTH old and new formatsYes
MigrateMove users to the new format over time (on login)Yes
ContractRemove support for the old format once almost nobody uses itNo

The key idea is in the middle column. During Expand and Migrate, both formats are valid at the same time. Nothing breaks. Only in Contract, at the very end, do you remove the old format — and by then it is safe.

Expand-Contract for password hashing

Expand
Migrate
Contract

Steps

1

Expand

App reads old + new hashes

2

Migrate

Upgrade each hash on login

3

Contract

Drop old hash support

The three safe phases of a zero-downtime hash upgrade.

Let us build each phase in code.

Phase 1: Expand — understand both formats

In the Expand phase, our login code must be able to verify a password against either the old hash format or the new one. We do not move anyone yet. We just make sure nobody is locked out.

A clean way to do this is to look at the stored hash and decide which method made it. Old hashes and new hashes usually look different, so we can tell them apart.

public enum HashKind
{
    LegacySha256,   // old, weaker format
    ModernPbkdf2    // new, stronger format
}
 
public static class HashFormat
{
    // New hashes start with a version marker like "v2:".
    public static HashKind Detect(string storedHash)
    {
        return storedHash.StartsWith("v2:")
            ? HashKind.ModernPbkdf2
            : HashKind.LegacySha256;
    }
}

Now our verification step can route the check to the right method. Both paths work, so existing users can still log in.

public bool VerifyPassword(string stored, string typedPassword)
{
    return HashFormat.Detect(stored) switch
    {
        HashKind.ModernPbkdf2 => ModernHasher.Verify(stored, typedPassword),
        HashKind.LegacySha256 => LegacyHasher.Verify(stored, typedPassword),
        _ => false
    };
}

This is the whole point of Expand: add the new ability without removing the old one. Both lanes of the bridge are open.

Figure 2: During Expand, the login path checks the hash format first, then verifies with the matching method. Nobody is locked out.

Phase 2: Migrate — upgrade on login

Now the clever part. Whenever a user logs in with an old hash and the password is correct, we have the real password in our hands for a split second. We use it to create a fresh new hash and save it. Next time, that user is already on the new format.

public async Task<bool> LoginAsync(string email, string typedPassword)
{
    var user = await _users.FindByEmailAsync(email);
    if (user is null) return false;
 
    var ok = VerifyPassword(user.PasswordHash, typedPassword);
    if (!ok) return false;
 
    // The password was correct. If it was an old hash, upgrade it now.
    if (HashFormat.Detect(user.PasswordHash) == HashKind.LegacySha256)
    {
        user.PasswordHash = ModernHasher.Hash(typedPassword); // "v2:..."
        await _users.UpdateAsync(user);
    }
 
    return true;
}

That is the entire migration engine. Every successful login on an old hash quietly rewrites it as a new hash. Over days and weeks, more and more users move to the new format — automatically, with zero downtime.

Login-time upgrade

Verify
Check format
Rehash
Save

Steps

1

Verify

Password correct?

2

Check format

Old hash?

3

Rehash

Make new v2 hash

4

Save

Store upgraded hash

How one login moves a single user from old to new format.

ASP.NET Core Identity does this for you

If you use ASP.NET Core Identity, you barely have to write this yourself. Identity has a built-in idea called SuccessRehashNeeded. When a user logs in and the password is right but stored with old settings, Identity rehashes it for you and saves it back.

For example, if you only want to increase the number of hashing iterations (making it slower to crack), you just change one option and Identity upgrades hashes on the next login:

builder.Services.Configure<PasswordHasherOptions>(options =>
{
    // Higher iteration count = stronger, slower to brute-force.
    options.IterationCount = 600_000;
});

Identity stores the iteration count inside each hash. On login, it compares the stored count to your new setting. If the stored one is lower, it returns SuccessRehashNeeded, and the SignInManager saves a fresh hash with the new count. You changed one number; the framework did the migration.

If you are moving from a totally different old system (like a legacy SHA-256 or an old ASP.NET Membership database), you can write a custom IPasswordHasher that verifies the old format and returns SuccessRehashNeeded so users roll onto the modern PBKDF2 hash as they sign in.

Watching the migration progress

A rolling migration is only finished when almost everyone has logged in at least once. Active users move fast. But some users never come back — old or abandoned accounts. You should watch the numbers so you know when it is safe to move to the Contract phase.

A simple query tells you how far along you are.

var total = await _db.Users.CountAsync();
var stillLegacy = await _db.Users
    .Where(u => !u.PasswordHash.StartsWith("v2:"))
    .CountAsync();
 
var migratedPercent = 100.0 * (total - stillLegacy) / total;
Console.WriteLine($"Migrated: {migratedPercent:F1}%");

Run this on a schedule and chart it. You are looking for the line to climb toward 100%.

DayUsers on new hashUsers still on old hashMigrated
Day 11,2008,80012%
Day 76,4003,60064%
Day 309,70030097%
Day 609,9505099.5%

Notice the shape. It climbs quickly at first because active users log in often. Then it slows down — the last few percent are sleepy accounts that rarely sign in. You do not wait forever for them. Once you reach a high number like 99%, you make a plan for the rest.

Figure 3: The migration state of a single user moves in one direction only — from legacy to modern. It can never go backward.

Phase 3: Contract — remove the old format

Once nearly everyone is migrated, you enter the Contract phase. This is where you remove support for the old hash format. But you must handle the small group of users who never logged in.

You have two gentle options for the stragglers:

  1. Force a password reset. Mark the remaining old accounts and send them a "please set a new password" email the next time they try to log in. Their new password gets a modern hash from the start.
  2. Invalidate quietly. For truly dead accounts, you can clear the old hash so it cannot be used at all, requiring a reset before access.

After that, you can safely delete the old verification code and, if you like, drop any old database columns you no longer need. The bridge's wooden lane is finally removed — and no one ever got stuck.

Handling the last few users

Reach 99%
Flag stragglers
Force reset
Remove old code

Steps

1

Reach 99%

Most users migrated

2

Flag stragglers

Find old hashes

3

Force reset

Email them to reset

4

Remove old code

Delete legacy path

The Contract phase deals with users who never logged in.

A note on database schema changes

Password hashing is a data migration, but the same expand-contract idea applies to schema changes in EF Core too. Say you want to rename a column or split one table into two. If you change the schema in one big risky step while the old app code is still running, the old code breaks.

Instead, expand first: add the new column alongside the old one. Deploy code that writes to both. Backfill the old rows in small batches. Switch reads to the new column. Then, much later, contract by dropping the old column. The table below shows the parallel between the two kinds of migration.

StepPassword hashingSchema change
ExpandRead old + new hash formatsAdd new column next to old one
MigrateRehash each user on loginBackfill rows in small batches
ContractRemove old hash codeDrop the old column

The same calm rhythm protects you in both cases. Never make the old and new code disagree at the same moment. Always overlap them.

Common mistakes to avoid

  • Trying to rehash everyone overnight. You physically cannot, because hashing is one-way. Do not promise it.
  • Removing the old format too early. If even one active user still has an old hash, dropping support locks them out. Watch your numbers first.
  • Forgetting the sleepy accounts. The last 1% never logs in. Plan a forced reset for them instead of waiting forever.
  • Logging the real password. During login you briefly hold the plain password. Never write it to logs or store it. Use it only to make the new hash, then let it go.
  • Skipping the version marker. Without a clear way to tell old hashes from new ones (like a v2: prefix), your code cannot route verification correctly.

Quick recap

  • A zero-downtime migration lets you change your data or schema while the app keeps running — like fixing a bridge one lane at a time.
  • Passwords force a slow migration because hashing is one-way. You can only upgrade a hash when the user types their real password during login.
  • The expand-contract pattern has three safe phases: Expand (support old and new together), Migrate (move users over time), and Contract (remove the old format at the end).
  • In ASP.NET Core Identity, SuccessRehashNeeded upgrades hashes for you when a user logs in. Changing IterationCount is enough to start a rolling upgrade.
  • Measure your progress with a simple query. Move to Contract only when nearly everyone (around 99%) has migrated, then handle stragglers with a forced reset.
  • The same expand-contract rhythm protects EF Core schema changes: add new, backfill, then drop old.

References and further reading

Related Posts