Short answer: to migrate ASP.NET Identity to ASP.NET Core Identity, copy your legacy AspNetUsers rows (including PasswordHash) into the new schema, fill in the columns ASP.NET Core Identity adds, and let the default password hasher verify old Identity 2.x hashes and upgrade them at each user's next login. This works for standard Identity 2.x hashes. If your app customized hashing, see the optional custom hasher in Step 3.
If you still have a production app running on the old Microsoft.AspNet.Identity packages — usually paired with OWIN (Open Web Interface for .NET, the middleware layer that older ASP.NET apps used for authentication) and running on the full .NET Framework — you already know it's not going anywhere on its own. That legacy stack, version 2.2.x, is effectively frozen. No new features, no modern security defaults, and it only runs on .NET Framework, which limits your hosting and tooling options going forward.
This guide walks through how to migrate ASP.NET Identity to ASP.NET Core Identity on .NET 10 (the current LTS, long-term support, release) without locking out your existing users. The steps and code also apply to .NET 8 and .NET 9, which are approaching end of support. We'll cover schema differences, password hash compatibility, claims and external logins, two-factor secrets, and a rollback plan — the parts most migration write-ups skip. At Codingvila we treat this as a data migration problem first and a code migration problem second, because that's where production incidents actually happen.
Timing matters: .NET 8 and .NET 9 both reach end of support on November 10, 2026, so if you are moving to ASP.NET Core anyway, target .NET 10.
In this guide:
- Why migrate
- Prerequisites
- Assessment: what to audit
- Step-by-step migration
- Common errors and how to fix them
- Rollback plan
- Verification
- Frequently asked questions
Why Migrate from ASP.NET Identity to ASP.NET Core Identity
ASP.NET Core Identity isn't just a renamed version of the old library. It's a full rewrite built on top of Entity Framework Core (EF Core, Microsoft's modern object-relational mapper for .NET) and designed for dependency injection from the ground up. The practical reasons to move:
- Framework longevity. Legacy ASP.NET Identity depends on .NET Framework, which receives only security and reliability fixes, not new runtime features. ASP.NET Core Identity ships with modern .NET and gets security updates, performance improvements, and new capabilities (such as passkey/WebAuthn support, available from .NET 10 as an opt-in feature that needs a newer Identity schema version) with each release.
- Cross-platform hosting. ASP.NET Core runs on Linux containers, which can cost less to host and scale more flexibly than Windows-only IIS deployments.
- Modern password hashing. ASP.NET Core Identity's default password hasher uses PBKDF2 (Password-Based Key Derivation Function 2, a standard algorithm for turning passwords into hashes) with a much higher iteration count than the legacy 1,000 (100,000 iterations with HMAC-SHA512 by default in recent versions) and a configurable
PasswordHasherOptionsthat's easy to tune as hardware gets faster. - Better extensibility. Custom claims, external login providers, and multi-tenant scenarios are first-class concerns in ASP.NET Core Identity, not workarounds bolted onto OWIN middleware.
The honest downside: this migration touches your user table, your password hashes, and your session/cookie handling simultaneously. Get the password hash step or the user data copy wrong and you lock out every user on their next login attempt. That's why the bulk of this article focuses on doing those parts correctly.
Prerequisites
Before you start, make sure you have the following in place.
- A working ASP.NET Core project targeting .NET 8, .NET 9, or .NET 10, with the
Microsoft.AspNetCore.Identity.EntityFrameworkCoreandMicrosoft.EntityFrameworkCore.SqlServerNuGet packages installed (swap the provider package if you're not on SQL Server). AddMicrosoft.EntityFrameworkCore.Designas well, which the EF Core tools need to generate migrations. - EF Core tools installed globally or as a local tool (
dotnet tool install --global dotnet-ef) so you can generate and apply migrations. - Read access to your existing legacy database — ideally a restored copy, not the live production database, for the first few passes.
- A maintenance window or a feature-flag strategy, since some steps (particularly the password hash transition) benefit from controlled rollout rather than a single cutover.
- A recent full backup of the production database, verified restorable, before you touch anything in production.
It's also worth confirming which .NET Framework version your legacy app targets and which version of Microsoft.AspNet.Identity.Core it references, since that affects exactly how the old PasswordHash column was generated.
Assessment: What to Audit in Your Current Setup
Before writing a line of migration code, spend time understanding what you actually have. This step gets skipped constantly, and it's where most mid-migration surprises come from.
First, inspect your existing AspNetUsers table schema. Legacy ASP.NET Identity typically includes columns like Id (a string, often a GUID), UserName, Email, EmailConfirmed, PasswordHash, SecurityStamp, PhoneNumber, TwoFactorEnabled, LockoutEndDateUtc, and AccessFailedCount. ASP.NET Core Identity's default schema is similar but not identical — notably LockoutEnd is a DateTimeOffset instead of DateTime, and Id defaults to a string but the underlying key type is configurable.
ASP.NET Core Identity also adds columns your legacy tables don't have: NormalizedUserName and NormalizedEmail on users, NormalizedName on roles, and a ConcurrencyStamp on both. Methods such as FindByNameAsync and FindByEmailAsync search the normalized columns, so if they are empty after your data copy, every user will look like they don't exist. It also adds two tables, AspNetRoleClaims and AspNetUserTokens.
Second, check what else your app stores in Identity-adjacent tables: AspNetRoles, AspNetUserRoles, AspNetUserClaims, AspNetUserLogins (external login providers like Google or Facebook OAuth), and any custom tables you've bolted on for two-factor authentication (2FA) secrets or application-specific profile data.
Third, identify every place in your codebase that touches UserManager, RoleManager, SignInManager, or raw OWIN authentication middleware calls like IAuthenticationManager.SignIn. These all need equivalent replacements in ASP.NET Core Identity, and custom extensions (like a custom IUserClaimsPrincipalFactory or a subclassed UserManager<T>) need to be ported, not just copy-pasted, because the base class signatures changed.
Finally, estimate your table sizes. A few thousand users is a straightforward single-pass migration. Millions of rows with active 24/7 login traffic changes your strategy toward a phased, zero-downtime approach, which we cover later in this article.
Step-by-Step Migration
Step 1: Scaffold ASP.NET Core Identity in the New Project
Start with a clean ASP.NET Core project (or your existing ASP.NET Core app if you're already mid-migration to the framework itself). Install the Identity packages and define your IdentityDbContext.
// Program.cs (.NET 8/9/10 minimal hosting model)
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<CodingvilaDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
{
options.Password.RequiredLength = 8;
options.Password.RequireNonAlphanumeric = true;
options.Lockout.MaxFailedAccessAttempts = 5;
options.SignIn.RequireConfirmedEmail = true;
})
.AddEntityFrameworkStores<CodingvilaDbContext>()
.AddDefaultTokenProviders();
builder.Services.AddControllersWithViews();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapDefaultControllerRoute();
app.Run();
This registers the Identity services, wires them to EF Core for persistence, and adds the default token providers used for things like password reset and email confirmation tokens. Note that RequireConfirmedEmail = true blocks sign-in for any user whose EmailConfirmed value is false, so make sure you copy that column from the legacy table in Step 3. ApplicationUser is your custom user class inheriting from IdentityUser, where you add any extra profile fields your legacy AspNetUsers table had.
public class ApplicationUser : IdentityUser
{
public string? LegacyUserId { get; set; } // keep the old PK for traceability during migration
public DateTime? DateRegistered { get; set; }
}
Keeping a LegacyUserId column, even temporarily, gives you a reliable way to cross-reference rows during the migration and to debug support tickets afterward. You can drop it once you're confident the migration is stable in production.
The database context inherits from IdentityDbContext<ApplicationUser> (in the Microsoft.AspNetCore.Identity.EntityFrameworkCore namespace), which supplies all the Identity tables.
public class CodingvilaDbContext : IdentityDbContext<ApplicationUser>
{
public CodingvilaDbContext(DbContextOptions<CodingvilaDbContext> options)
: base(options)
{
}
}
Step 2: Generate and Review the EF Core Migration
Run the EF Core migration commands to create the Identity schema in your target database. Point them at a new, empty database (or at least a separate schema), not the legacy database: the legacy tables use the same names, such as AspNetUsers, and would collide.
dotnet ef migrations add InitialIdentitySchema --context CodingvilaDbContext
dotnet ef database update --context CodingvilaDbContext
Before applying this against production, open the generated migration file and read it. Confirm the column types match what you expect, particularly around LockoutEnd (now DateTimeOffset) and the primary key type you've chosen for users and roles. For production, many teams generate a SQL script with dotnet ef migrations script and review it instead of running database update directly. If your legacy system used integer IDs instead of strings, you'll need to configure IdentityUser<int> and a matching IdentityDbContext<ApplicationUser, IdentityRole<int>, int> — changing the key type after the fact is painful, so decide this before scaffolding.
Expected result: the new database contains the Identity tables (AspNetUsers, AspNetRoles, AspNetUserRoles, AspNetUserClaims, AspNetUserLogins, AspNetRoleClaims, AspNetUserTokens) and your __EFMigrationsHistory table, all empty of user data.
Passkeys are opt-in. Migrating your existing users does not turn on passkeys. To use them in .NET 10 you opt into Identity schema version 3 and apply an additional migration that creates the AspNetUserPasskeys table. Do this as a separate change after the user migration is stable, so you are not changing the schema and the passwords at the same time. On SQL Server, review the generated migration first, because a reported issue shows a key-length warning for that table.
options.Stores.SchemaVersion = IdentitySchemaVersions.Version3;
Step 3: Migrate User Data with a Password Hash Compatibility Layer
This is the step that determines whether your users can log in tomorrow. Legacy ASP.NET Identity 2.x's default hasher produced a 49-byte value (Base64-encoded): a 1-byte format marker (0x00), a 16-byte salt, and a 32-byte subkey derived with PBKDF2-SHA1 and 1,000 iterations. ASP.NET Core Identity's default PasswordHasher<TUser> writes a newer format with a much higher iteration count.
You can't re-hash every password up front, because you don't have the plaintext passwords. The good news is that you don't need to. The default ASP.NET Core Identity hasher recognizes the Identity 2.x format, verifies it, and returns SuccessRehashNeeded. UserManager then re-hashes the password with the modern algorithm and saves it automatically after a successful login. This is the same "lazy migration" pattern used by most systems that need to change hashing algorithms without forcing a mass password reset.
So the core of this step is a data copy: move the existing PasswordHash values as-is, and fill in the columns the new schema requires. The examples below assume both databases are on the same SQL Server instance and that your legacy IDs are strings; adjust names to match your environment.
Before copying, run two quick checks against the legacy database. First, look for users with no password hash (for example, accounts that only use external logins). They can't sign in with a password, which is expected, but you should know how many there are. Second, look for usernames that would collide after normalization.
SELECT COUNT(*) FROM LegacyDb.dbo.AspNetUsers WHERE PasswordHash IS NULL;
SELECT UPPER(UserName), COUNT(*)
FROM LegacyDb.dbo.AspNetUsers
GROUP BY UPPER(UserName)
HAVING COUNT(*) > 1;
Now copy the users:
-- Assumes LegacyDb and NewDb are on the same SQL Server instance.
INSERT INTO NewDb.dbo.AspNetUsers
(Id, UserName, NormalizedUserName, Email, NormalizedEmail, EmailConfirmed,
PasswordHash, SecurityStamp, ConcurrencyStamp, PhoneNumber, PhoneNumberConfirmed,
TwoFactorEnabled, LockoutEnd, LockoutEnabled, AccessFailedCount, LegacyUserId)
SELECT
Id, UserName, UPPER(UserName), Email, UPPER(Email), EmailConfirmed,
PasswordHash, SecurityStamp, CONVERT(nvarchar(36), NEWID()), PhoneNumber, PhoneNumberConfirmed,
TwoFactorEnabled,
CASE WHEN LockoutEndDateUtc IS NULL THEN NULL
ELSE TODATETIMEOFFSET(LockoutEndDateUtc, 0) END,
LockoutEnabled, AccessFailedCount, Id
FROM LegacyDb.dbo.AspNetUsers;
A few details matter here. Reusing the legacy Id keeps role, claim, and login rows linkable without remapping. UPPER mimics ASP.NET Core Identity's default normalization, so test any usernames or emails with non-ASCII characters. TODATETIMEOFFSET(..., 0) converts the legacy UTC DateTime into a DateTimeOffset with a zero offset. Keep SecurityStamp unchanged so existing token-based flows behave consistently.
Expected result: FindByNameAsync returns migrated users, and the first successful login for each user replaces their old hash with a new-format hash.
Next, copy the related tables. Run these after the users insert, because of foreign keys: roles first, then role assignments, claims, and external logins.
INSERT INTO NewDb.dbo.AspNetRoles (Id, Name, NormalizedName, ConcurrencyStamp)
SELECT Id, Name, UPPER(Name), CONVERT(nvarchar(36), NEWID())
FROM LegacyDb.dbo.AspNetRoles;
INSERT INTO NewDb.dbo.AspNetUserRoles (UserId, RoleId)
SELECT UserId, RoleId FROM LegacyDb.dbo.AspNetUserRoles;
-- Omit Id: it is an identity column in the new table.
INSERT INTO NewDb.dbo.AspNetUserClaims (UserId, ClaimType, ClaimValue)
SELECT UserId, ClaimType, ClaimValue FROM LegacyDb.dbo.AspNetUserClaims;
INSERT INTO NewDb.dbo.AspNetUserLogins (LoginProvider, ProviderKey, ProviderDisplayName, UserId)
SELECT LoginProvider, ProviderKey, NULL, UserId FROM LegacyDb.dbo.AspNetUserLogins;
Optional: a custom hasher. For stock Identity 2.x hashes you can skip the rest of this step. Only if your legacy app customized hashing (a different salt length, iteration count, or algorithm) do you need your own verification logic. The class below shows the pattern, with the default 2.x layout filled in as an example. Adjust the byte layout to match your real data.
public class LegacyPasswordHasher : PasswordHasher<ApplicationUser>
{
public override PasswordVerificationResult VerifyHashedPassword(
ApplicationUser user, string hashedPassword, string providedPassword)
{
// First, try the modern ASP.NET Core Identity format.
var result = base.VerifyHashedPassword(user, hashedPassword, providedPassword);
if (result != PasswordVerificationResult.Failed)
{
return result;
}
// Fall back to verifying against the legacy ASP.NET Identity (2.x) hash format.
if (VerifyLegacyHash(hashedPassword, providedPassword))
{
// Signal that the caller should re-hash and persist the new hash.
return PasswordVerificationResult.SuccessRehashNeeded;
}
return PasswordVerificationResult.Failed;
}
private static bool VerifyLegacyHash(string hashedPassword, string providedPassword)
{
var decodedHashedPassword = Convert.FromBase64String(hashedPassword);
// Legacy format: 1 byte format marker (0x00), 16 bytes salt, 32 bytes subkey (PBKDF2-SHA1, 1000 iterations)
if (decodedHashedPassword.Length != 49 || decodedHashedPassword[0] != 0x00)
{
return false;
}
var salt = new byte[16];
Buffer.BlockCopy(decodedHashedPassword, 1, salt, 0, 16);
var storedSubkey = new byte[32];
Buffer.BlockCopy(decodedHashedPassword, 17, storedSubkey, 0, 32);
using var deriveBytes = new Rfc2898DeriveBytes(providedPassword, salt, 1000, HashAlgorithmName.SHA1);
var computedSubkey = deriveBytes.GetBytes(32);
return CryptographicOperations.FixedTimeEquals(storedSubkey, computedSubkey);
}
}
If you use a custom hasher, register it in place of the default one. Because it's registered via DI as the app's IPasswordHasher<ApplicationUser>, CheckPasswordSignInAsync already invokes it internally, and UserManager automatically re-hashes and persists the new password hash when VerifyHashedPassword returns SuccessRehashNeeded — no manual re-verification step is needed in your login flow.
builder.Services.AddScoped<IPasswordHasher<ApplicationUser>, LegacyPasswordHasher>();
public async Task<SignInResult> LoginAsync(string userName, string password)
{
var user = await _userManager.FindByNameAsync(userName);
if (user is null) return SignInResult.Failed;
// Because LegacyPasswordHasher is registered as the app's IPasswordHasher<ApplicationUser>,
// CheckPasswordSignInAsync already invokes it internally, and when it returns
// SuccessRehashNeeded, UserManager automatically re-hashes and persists the new
// password hash before this call returns. No manual re-verification is needed here.
var result = await _signInManager.CheckPasswordSignInAsync(user, password, lockoutOnFailure: true);
return result;
}
Note that CheckPasswordSignInAsync only verifies the password and updates lockout counters. It does not issue the authentication cookie. To sign the user in, call PasswordSignInAsync instead. Also, SignInResult here is Microsoft.AspNetCore.Identity.SignInResult, not the MVC type of the same name.
A quick but important note: the exact byte layout of the legacy hash (format marker, salt length, subkey length, iteration count) depends on which version of Microsoft.AspNet.Identity.Core your old app used and whether it customized the hasher. Before trusting any of this against production data, verify the layout against your actual stored hashes in a non-production environment — decode a handful of known PasswordHash values and confirm the byte lengths and iteration count match what's assumed above. Treat the numbers here as the commonly documented defaults for Identity 2.x, not a guarantee for every historical configuration.
Step 4: Migrate Roles, Claims, and External Logins
Password hashes get the most attention, but claims and external logins are where migrations quietly break things like "remember this OAuth connection" or custom authorization policies.
If you ran the SQL copy in Step 3, your roles, role assignments, claims, and external logins are already in the new database, and you can skip to the notes on external logins and two-factor authentication below. The C# example here is an alternative for teams that prefer to migrate through RoleManager and UserManager. It creates roles that reuse the legacy role IDs, then copies user claims. It assumes the users have already been copied with their original IDs, and LegacyDbContext is your own EF Core model of the old schema. Reusing the role IDs matters: the legacy AspNetUserRoles rows point at those IDs, so you can copy that table as-is. Without that copy, users keep their accounts but lose all their role assignments.
public async Task MigrateRolesAndClaimsAsync(
LegacyDbContext legacyDb,
RoleManager<IdentityRole> roleManager,
UserManager<ApplicationUser> userManager)
{
var legacyRoles = await legacyDb.Roles.ToListAsync();
foreach (var legacyRole in legacyRoles)
{
if (!await roleManager.RoleExistsAsync(legacyRole.Name))
{
// Reuse the legacy role Id so copied AspNetUserRoles rows still match.
await roleManager.CreateAsync(new IdentityRole(legacyRole.Name) { Id = legacyRole.Id });
}
}
var legacyClaims = await legacyDb.UserClaims.ToListAsync();
foreach (var legacyClaim in legacyClaims)
{
var user = await userManager.FindByIdAsync(legacyClaim.UserId);
if (user is null) continue;
var existingClaims = await userManager.GetClaimsAsync(user);
var alreadyExists = existingClaims.Any(c =>
c.Type == legacyClaim.ClaimType && c.Value == legacyClaim.ClaimValue);
if (!alreadyExists)
{
await userManager.AddClaimAsync(user, new Claim(legacyClaim.ClaimType, legacyClaim.ClaimValue));
}
}
}
This loop is simple and idempotent (safe to re-run), but it makes several database calls per claim. For large tables, the set-based SQL copy in Step 3 is much faster. Use one approach or the other, not both on the same data.
For external login providers (Google, Microsoft, Facebook, or custom OAuth/SAML providers), the AspNetUserLogins table maps a LoginProvider and ProviderKey to a user. ASP.NET Core Identity uses the same conceptual model through UserManager.AddLoginAsync, but the provider display names and callback paths usually need reconfiguring, since ASP.NET Core's authentication handlers (AddGoogle, AddMicrosoftAccount, etc.) register differently than the old OWIN middleware did. Migrate the raw login records with a direct insert into the new AspNetUserLogins table structure, but plan to test each external provider's sign-in flow end to end afterward, because a mismatched ProviderKey format will silently fail to link accounts.
Two-factor authentication deserves its own callout. Legacy Identity 2.x shipped phone and email token providers, which generate codes from the user's security stamp (so copying SecurityStamp and TwoFactorEnabled carries them over). It had no built-in authenticator-app support. If your app added its own TOTP (time-based one-time password) secret storage, that secret can usually be carried over, since the TOTP algorithm itself hasn't changed. ASP.NET Core Identity stores the authenticator key in AspNetUserTokens with the login provider name [AspNetUserStore] and the token name AuthenticatorKey. Test a real 2FA login with a carried-over secret in staging before assuming it works.
Step 5: Handle Data Protection Keys and Antiforgery Tokens
This part gets skipped in almost every migration guide, and it causes a specific, confusing symptom: users get signed out immediately after a successful login, or existing authentication cookies throw decryption errors.
ASP.NET Core uses the Data Protection API to encrypt authentication cookies, antiforgery tokens, and other sensitive data. A legacy OWIN app that used MachineKey based protection produces cookies that ASP.NET Core Identity does not read by default. The old cookie also has a different name from the new one, so ASP.NET Core simply ignores it. Sharing cookies between the two stacks is possible (Microsoft documents an interop approach for side-by-side migrations), but it takes extra configuration on both apps. For a one-time cutover, the simpler plan is to treat the migration as the moment every user signs in again, and to tell users to expect that.
For the same reason, password-reset and email-confirmation links the old app already sent will most likely stop working after cutover, because they were protected by the old system. Tell users to request a new link.
The cookie settings below make the new app's behavior explicit. A 14-day sliding expiration is already the default for the Identity application cookie, so you only need this block if you want different values.
builder.Services.ConfigureApplicationCookie(options =>
{
options.ExpireTimeSpan = TimeSpan.FromDays(14);
options.SlidingExpiration = true;
options.LoginPath = "/Account/Login";
});
If you're running multiple instances behind a load balancer, also configure Data Protection to persist keys to a shared location (like a database table, Azure Blob Storage, or a shared file system) rather than the default per-machine key ring, otherwise users will get logged out whenever requests land on a different server instance. The UNC path below only works on Windows hosts; on Linux containers use a mounted volume or blob/database storage instead. Also consider encrypting the stored keys at rest, for example with a certificate.
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(@"\\shared-storage\keys"))
.SetApplicationName("CodingvilaApp");
Step 6: Zero-Downtime Strategy for Large User Tables
If you're dealing with a large, actively used user table, a single big-bang cutover is risky. A more conservative approach that works well in practice:
- Stand up the new ASP.NET Core Identity schema alongside the legacy database, without removing the old tables yet.
- Run a background batch job that copies users, roles, and claims in chunks (for example, 5,000 rows at a time) during low-traffic hours, tracking progress with the
LegacyUserIdcolumn mentioned earlier. - Decide, per user, which system is the source of truth, and do not let one user change their password in both systems. Once a user signs in through the new app, their password is stored in the new format, and the legacy app can no longer verify it.
- Once the batch copy finishes and spot checks pass, cut all traffic to the new system and keep the legacy database as a frozen read-only reference for a defined retention period, typically 30 to 90 days depending on your compliance requirements.
This avoids a hard maintenance window and lets you catch data issues on a subset of users before they affect everyone. One caution: anyone who registers, changes a password, or updates their profile in the legacy system after their row was copied will be out of date in the new one. Plan a final delta sync right before cutover (or freeze those actions during the copy window).
For most teams, a short planned cutover with a final delta sync is simpler and safer than running both systems side by side for a long period.
Common Errors and How to Fix Them
Password hash format mismatches are the most frequent issue. If password verification always fails for migrated users, double-check the legacy hash's byte layout — Identity 2.x configurations sometimes used different iteration counts or SHA variants depending on how PasswordHasher was customized in the original app. Decode a sample hash manually and compare byte lengths before assuming the standard layout applies. If the layout is customized, use the optional custom hasher from Step 3.
If FindByNameAsync or FindByEmailAsync returns null for users you know exist, check NormalizedUserName and NormalizedEmail. These columns don't exist in the legacy schema and must be populated during the copy.
If users with the right password are still refused at sign-in, check EmailConfirmed. With RequireConfirmedEmail = true, any migrated user whose value is false is blocked. Copy the legacy value, and don't default it to false.
Lockout behavior changing unexpectedly is another common surprise. Legacy Identity used LockoutEndDateUtc as a nullable DateTime; ASP.NET Core Identity uses LockoutEnd as a DateTimeOffset?. If you migrate the raw value without converting the offset correctly, you can end up with users permanently locked out or never locked out at all. Always run this conversion explicitly: in SQL use TODATETIMEOFFSET(legacyValue, 0), and in C# use DateTime.SpecifyKind(legacyValue, DateTimeKind.Utc) before converting to DateTimeOffset.
Claims not appearing in the new application's ClaimsPrincipal (the object representing the signed-in user's identity and claims) usually traces back to a custom IUserClaimsPrincipalFactory in the old app that added claims dynamically at sign-in time rather than storing them in AspNetUserClaims. You'll need to reimplement that factory in ASP.NET Core Identity by overriding UserClaimsPrincipalFactory<TUser>.CreateAsync, not just copy the claims table.
External login failures after migration are typically caused by a ProviderKey mismatch — the key stored by the old OWIN middleware may differ from the identifier the new ASP.NET Core handler reads from the provider, depending on which claim each one used. If a user can't link their existing Google or Microsoft login, compare a stored ProviderKey with what the new handler receives, and be ready to fall back to a manual account-linking flow rather than assuming the old key is still valid.
Finally, watch for role name casing differences. Legacy Identity role lookups were sometimes case-insensitive depending on the database collation; ASP.NET Core Identity's default behavior depends on your database's collation settings too, but role claims and authorization policy checks ([Authorize(Roles = "Admin")]) are string comparisons under the hood, so inconsistent casing between the two systems can cause authorization checks to silently fail.
Rollback Plan
Have this written down before you start, not improvised during an incident. The simplest rollback for a phased migration is to keep the legacy application deployable and the legacy database untouched (read-write) until you're confident in the new system — don't decommission the old app's infrastructure in the same release that ships the migration. If something breaks after cutover, routing traffic back to the legacy app and legacy database is far less risky than trying to reverse a partial data migration under pressure. Keep your batch migration job idempotent (safe to re-run without duplicating data) so that if you do roll back and need to re-run the migration later, it picks up where it left off rather than creating duplicate rows.
Two limits to plan for. First, the legacy database won't contain anything that happened after cutover (new registrations, password changes, profile edits), so decide in advance how you will copy that data back or accept losing it. Second, once a user has logged in and been re-hashed in the new system, the legacy Identity 2.x hasher cannot verify that new-format hash. After a rollback, those users would need a password reset unless you kept their original hash in the legacy database. Because of this, never overwrite the legacy PasswordHash values while the rollback window is open.
Verification
Once the migration is deployed, verify it methodically rather than assuming it worked because nobody complained yet.
Run through a login test matrix covering at least: an existing user with a legacy password hash logging in for the first time post-migration (confirm the re-hash occurs and a second login succeeds using the new hash), a user resetting their password through the "forgot password" flow, a user with an external login provider signing in, and a user with two-factor authentication enabled completing a full 2FA challenge.
[Fact]
public async Task ExistingUser_WithLegacyHash_CanSignIn_AndGetsRehashed()
{
// Arrange: seed a user with a known legacy PBKDF2-SHA1 hash
var user = await _userManager.FindByNameAsync("legacy.user@codingvila.com");
var initialHash = user!.PasswordHash;
// Act: CheckPasswordSignInAsync verifies the password without needing an HttpContext
var result = await _signInManager.CheckPasswordSignInAsync(
user, "KnownPlaintextPassword!1", lockoutOnFailure: true);
// Assert
Assert.True(result.Succeeded);
var updatedUser = await _userManager.FindByNameAsync("legacy.user@codingvila.com");
Assert.NotEqual(initialHash, updatedUser!.PasswordHash);
}
This kind of integration test, run against a seeded test database rather than production, gives you a repeatable way to confirm the re-hash logic works before you ever touch real user accounts. The seeded user needs EmailConfirmed set to true, or the sign-in will fail for the reason described under Common Errors. Pair the test with manual checks of your authorization policies (confirm role-gated and claim-gated pages still return the right access results) and a check of your sign-out flow, since cookie configuration changes sometimes break sign-out silently.
To track progress after cutover, count how many hashes have been upgraded. Hashes in the newer format start with the byte 0x01, which in Base64 usually appears as the prefix AQAAAA. Check this against a few real rows in staging before relying on it.
SELECT SUM(CASE WHEN PasswordHash LIKE 'AQAAAA%' THEN 1 ELSE 0 END) AS Upgraded,
COUNT(*) AS Total
FROM NewDb.dbo.AspNetUsers
WHERE PasswordHash IS NOT NULL;
Also check your application logs for a spike in failed login attempts in the hours immediately after cutover. A small bump is expected — some users will have stale browser sessions — but a sustained high failure rate usually points to a hash format or lockout conversion bug that needs immediate attention.
Frequently Asked Questions
Do users have to reset their passwords after migrating?
Not if their hashes use the standard Identity 2.x format. The default ASP.NET Core Identity hasher verifies those hashes and upgrades them the next time each user signs in.
Will users be signed out after the migration?
Yes, in a typical cutover. Legacy OWIN cookies are not read by ASP.NET Core Identity by default, so users sign in again once.
Can I run the old and new apps side by side?
Only with care. A password upgraded in the new system cannot be verified by the legacy system, so decide which system owns each user.
Key Takeaways
- Copy legacy
PasswordHashvalues as-is. ASP.NET Core Identity's default hasher verifies Identity 2.x hashes and upgrades them on the next successful login. - Populate the new columns:
NormalizedUserName,NormalizedEmail, roleNormalizedName, andConcurrencyStamp. Also copyEmailConfirmed. - Convert
LockoutEndDateUtcto aDateTimeOffsetexplicitly, and copy user-role assignments along with the roles. - Expect every user to sign in again after cutover, expect old reset and confirmation links to stop working, and share Data Protection keys across instances.
- Keep the legacy app and database intact until the new system is proven, and remember that re-hashed passwords can't be verified by the legacy system.
- Treat passkeys as a separate, opt-in follow-up that needs Identity schema version 3.
Conclusion
Migrating from legacy ASP.NET Identity to ASP.NET Core Identity on .NET 10 (or .NET 8 and 9) is mostly a data problem wearing a code problem's clothes. The core risk isn't the UserManager API surface, it's getting password hashes, normalized columns, lockout timestamps, claims, and external login keys across correctly without breaking a single user's ability to sign in. If you migrate ASP.NET Identity to ASP.NET Core Identity using lazy re-hashing, a careful data copy, and a genuine rollback plan, you avoid the mass lockout scenario that makes this kind of migration so nerve-wracking. As a next step, review Microsoft's official ASP.NET Core Identity documentation for your target .NET version, including the passkey guidance if you plan to adopt WebAuthn.
