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.
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.
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.
| Phase | What you do | Is the old format still allowed? |
|---|---|---|
| Expand | Teach the app to understand BOTH old and new formats | Yes |
| Migrate | Move users to the new format over time (on login) | Yes |
| Contract | Remove support for the old format once almost nobody uses it | No |
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
Steps
Expand
App reads old + new hashes
Migrate
Upgrade each hash on login
Contract
Drop old hash support
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.
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
Steps
Verify
Password correct?
Check format
Old hash?
Rehash
Make new v2 hash
Save
Store upgraded hash
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%.
| Day | Users on new hash | Users still on old hash | Migrated |
|---|---|---|---|
| Day 1 | 1,200 | 8,800 | 12% |
| Day 7 | 6,400 | 3,600 | 64% |
| Day 30 | 9,700 | 300 | 97% |
| Day 60 | 9,950 | 50 | 99.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.
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:
- 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.
- 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
Steps
Reach 99%
Most users migrated
Flag stragglers
Find old hashes
Force reset
Email them to reset
Remove old code
Delete legacy path
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.
| Step | Password hashing | Schema change |
|---|---|---|
| Expand | Read old + new hash formats | Add new column next to old one |
| Migrate | Rehash each user on login | Backfill rows in small batches |
| Contract | Remove old hash code | Drop 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,
SuccessRehashNeededupgrades hashes for you when a user logs in. ChangingIterationCountis 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
- A Practical Demo of Zero-Downtime Migrations Using Password Hashing — Milan Jovanović
- Safely migrating passwords in ASP.NET Core Identity with a custom PasswordHasher — Andrew Lock
- Exploring the ASP.NET Core Identity PasswordHasher — Andrew Lock
- Increasing Password Hashing Iterations with ASP.NET Core Identity — Scott Sauber
- Migrating Legacy Password Hashes: the Fallback Password Hasher Pattern — Stuart Greig
- Database Migrations: The Expand-Contract Pattern — Enol Casielles
Related Posts
Soft Delete with EF Core: Delete Data Without Losing It
Learn soft delete in EF Core the right way. Use an interceptor and global query filters to hide deleted rows automatically, with simple examples, diagrams, code, and best practices for .NET 10.
5 EF Core Features You Need to Know (Beginner Friendly)
Learn 5 must-know EF Core features with simple examples: change tracking, AsNoTracking, eager loading, bulk ExecuteUpdate, and query filters.
EF Core Query Splitting: Fix Slow Queries and Cartesian Explosion
Learn how EF Core query splitting (AsSplitQuery) fixes the cartesian explosion problem with simple examples, diagrams, and real performance numbers. Know when to split and when not to.
Multi-Tenant Applications With EF Core: A Beginner's Guide
Learn multi-tenancy in EF Core the simple way. Isolate tenant data with global query filters, ITenantService, and named filters in .NET 10, with diagrams and code.
Optimizing SQL Performance with Indexing Strategies for Faster Queries
Learn SQL indexing the easy way: clustered, nonclustered, covering and composite indexes, with simple diagrams and C# examples to make your queries fast.
Eager Loading of Child Entities in EF Core: A Beginner's Guide
Learn eager loading in EF Core with Include and ThenInclude. Load child entities in one query, avoid the N+1 problem, and use filtered Include with simple examples.