Login is the one slice every other slice depends on. A WebForms or Classic ASP application that authenticates with Forms Authentication and stores passwords in aspnet_Membership will keep working indefinitely, right up to the day an auditor, an insurer or a penetration test asks for MFA and nobody can add it. That is the trigger we see most often on this particular piece of work, and it arrives with a date attached.
The instinct is to rewrite login first, in one go, and make everyone reset their password. It is the wrong move in a strangler migration. Identity is not a slice you can cut over quietly, because a failed login is the one bug that stops the whole company rather than one department. This tutorial walks through the sequence we use instead: keep one cookie both applications trust, move the password store to ASP.NET Core Identity without a mass reset, then hand authentication to the customer's identity provider when the business is ready for MFA.
It assumes a .NET Framework WebForms application in production, a .NET 8 slice being built beside it, and a routing shim in front of both. The shim is a prerequisite; if you do not have one yet, build it first.
Step 0: write down what login actually does today
Before anything, read the real behavior out of the legacy app. It is never only "check the password".
- Which provider is configured:
SqlMembershipProvider,SqlClientFormsAuthenticationMembershipProvider, or a hand-rolled table with its own hashing. - The
passwordFormatinweb.config:Clear,EncryptedorHashed, and thehashAlgorithmType. Membership defaults changed across framework versions, so read the file rather than assuming SHA-1 or SHA-256. - The
machineKeyelement. If it is missing, the keys are auto-generated per server and the cookie only works on one machine. That is a fact you need before you plan anything. - Cookie name, timeout,
slidingExpiration, and whetherrequireSSLis set. - Everything that happens after a successful login: rows written to an audit table, a "must change password" flag, account lockout counters, a licence check, roles loaded into session, a last-login column someone's report depends on.
- Password rules as enforced, not as documented: minimum length, required characters, history, expiry.
Capture the last item as characterization tests before you touch code. Login is behavior like anything else, and the recorded version of "wrong password three times locks the account for ten minutes" is worth more than the paragraph in the handbook. The harness pattern is in golden-master tests for legacy web apps.
Step 1: one cookie, two applications
While both applications serve traffic, they must agree on who the user is. The mechanism is a shared cookie and a shared key ring.
Move the legacy application from System.Web Forms Authentication to OWIN cookie authentication first. On a typical WebForms app this is a day or two of work: add the OWIN packages, add a Startup class, and replace FormsAuthentication.SetAuthCookie with a call that signs in a claims identity.
// Legacy .NET Framework app, Startup.cs
public void Configuration(IAppBuilder app)
{
app.UseCookieAuthentication(new CookieAuthenticationOptions
{
AuthenticationType = "Identity.Application",
CookieName = ".AspNet.SharedAuth",
CookieDomain = ".example.com",
LoginPath = new PathString("/login.aspx"),
TicketDataFormat = new AspNetTicketDataFormat(
new DataProtectorShim(
DataProtectionProvider
.Create(new DirectoryInfo(@"\\keys.example.local\authkeys"))
.CreateProtector(
"Microsoft.AspNetCore.Authentication.Cookies." +
"CookieAuthenticationMiddleware",
"Identity.Application",
"v2")))
});
}
That comes from Microsoft.Owin.Security.Interop, and Microsoft documents the matching setup in Share authentication cookies among ASP.NET apps. The .NET 8 side points at the same directory:
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(@"\\keys.example.local\authkeys"))
.SetApplicationName("SharedCookieApp");
builder.Services.AddAuthentication("Identity.Application")
.AddCookie("Identity.Application", o =>
{
o.Cookie.Name = ".AspNet.SharedAuth";
o.Cookie.Domain = ".example.com";
o.LoginPath = "/login.aspx";
});
Four things have to match exactly: the cookie name, the cookie domain and path, the purpose strings above, and the key ring both sides read. Get one wrong and the symptom is identical every time — the new slice bounces an authenticated user back to the legacy login page, which redirects to the slice, which bounces again. If you see a redirect loop, compare those four before you debug anything else.
The key ring is now shared infrastructure. Back it up, restrict it to the two service accounts, and if you are on Windows without a common file share, use a SQL Server table or Azure Blob Storage as the store instead. Losing it logs out every user at once.
Test with the boring cases: log in on the legacy app and navigate to a slice page, log in on the slice and go back to a legacy page, sign out on either side and confirm both sides forget you. Sign-out is the one people skip and the one auditors ask about.
Step 2: move the password store without a mass reset
Now the new stack can own users. AspNetCore.Identity will not read Membership hashes, but you can teach it to.
Copy the users across first — user name, email, the hash, the salt, the format, lockout state, roles — into the Identity schema, keeping the legacy hash in PasswordHash with a marker for its format. Then supply a password hasher that understands both, verifies the old format, and reports SuccessRehashNeeded so Identity rewrites the record on the next successful login:
public class MembershipCompatHasher : IPasswordHasher<AppUser>
{
private readonly PasswordHasher<AppUser> modern = new();
public string HashPassword(AppUser user, string password)
=> modern.HashPassword(user, password);
public PasswordVerificationResult VerifyHashedPassword(
AppUser user, string hashedPassword, string providedPassword)
{
if (!hashedPassword.StartsWith("MEMBERSHIP:"))
return modern.VerifyHashedPassword(user, hashedPassword, providedPassword);
// MEMBERSHIP:<base64 salt>:<base64 hash>
var parts = hashedPassword.Split(':');
var salt = Convert.FromBase64String(parts[1]);
var expected = parts[2];
var bytes = Encoding.Unicode.GetBytes(providedPassword);
var buffer = new byte[salt.Length + bytes.Length];
Buffer.BlockCopy(salt, 0, buffer, 0, salt.Length);
Buffer.BlockCopy(bytes, 0, buffer, salt.Length, bytes.Length);
var actual = Convert.ToBase64String(SHA1.HashData(buffer));
return CryptographicOperations.FixedTimeEquals(
Encoding.ASCII.GetBytes(actual), Encoding.ASCII.GetBytes(expected))
? PasswordVerificationResult.SuccessRehashNeeded
: PasswordVerificationResult.Failed;
}
}
Read the algorithm out of web.config rather than copying that SHA1 call blindly; Membership concatenated the salt with the UTF-16 bytes of the password, and the hash algorithm varies by version and by whatever a previous developer changed. Prove it before you migrate anything: take a handful of test accounts with known passwords, run the verification against the production hashes offline, and confirm every one returns SuccessRehashNeeded. If you cannot reproduce the hashes for known passwords, stop. A silent mismatch here means every user is locked out on cutover day.
Two operational notes. SuccessRehashNeeded only rehashes users who log in, so a year later you will still have dormant accounts on the old format; report on that count and expire the stragglers deliberately. And if passwordFormat is Clear or Encrypted, do not carry the scheme forward — hash on first login and treat the old column as a breach-in-waiting until it is dropped.
Step 3: hand authentication to the identity provider
Local passwords are usually what the audit finding is actually about. Once identity lives in the new stack, the last step is to stop holding passwords at all: OpenID Connect against Entra ID, Okta or whatever the customer already uses for email, with MFA and account lifecycle handled there.
The shim makes this a small change. Add OIDC to the .NET 8 side, keep issuing the same shared cookie after the external sign-in, and the legacy application keeps trusting the cookie without knowing anything changed. Legacy /login.aspx becomes a redirect to the new sign-in endpoint.
Two things to plan for. Service accounts and integrations that post credentials to the legacy login form will break, so inventory them first — batch jobs, a Crystal Reports scheduler, that Excel connection someone built in 2014. And keep local login working for a named break-glass account, tested, so an IdP outage is not a company outage.
What we would not do
- Force a global password reset to save the compatibility work. It generates a support queue, trains users to expect password emails, and buys a few days of engineering time at most.
- Run two user tables in parallel and sync them. One is the source of truth. The other is a copy the shim reads or does not read at all.
- Roll a custom SSO token between the two applications. A signed cookie both frameworks already understand is less code and better reviewed than anything we would write for it.
- Migrate identity last. Every slice built before login moves needs its own answer for who the user is. Do it early, once, and each later slice gets it for free.