Every one of these systems has a login story, and on intranet line-of-business apps it is usually the same one: <authentication mode="Windows" />, Integrated Windows Authentication turned on in IIS, and business code that reads User.Identity.Name and gets CONTOSO\jsmith. Nobody has thought about it in fifteen years because it works. Domain-joined machine, browser sends the ticket, user never sees a prompt.
Two things are ending that quiet. Microsoft has deprecated NTLM and is steering Windows authentication toward Kerberos and then away from on-premises tickets entirely. And the people buying a modernization are usually already on Microsoft Entra ID for email, with conditional access and MFA policies that the intranet app silently does not participate in. Auditors notice that.
This tutorial is the practical version of that move: measure what the legacy app actually relies on, put the new .NET 8 slice on Entra ID with OpenID Connect, and keep both halves agreeing about who the user is while the strangler migration runs. It assumes the legacy app stays up the whole time, because it will.
Step 1: Measure what "Windows auth" means in this app
Before any identity work, find out what the code does with the identity. There are usually three distinct uses tangled together, and they migrate differently.
Search the source for the call sites:
# Identity and role checks
Get-ChildItem -Recurse -Include *.cs,*.vb,*.asp,*.aspx,*.ascx,*.bas,*.cls |
Select-String -Pattern 'User\.Identity|WindowsIdentity|IsInRole|LOGON_USER|AUTH_USER|WindowsPrincipal|Impersonat' |
Group-Object Path | Sort-Object Count -Descending |
Select-Object Count, Name -First 40
Sort the hits into three buckets:
- Who is this?
User.Identity.Nameused as a key — stamped intoupdated_by, joined against anemployeestable, written to an audit trail. This is the bucket that decides your mapping work. - What may they do?
User.IsInRole("CONTOSO\\AP-Clerks")or aRolescheck against AD groups. This becomes claims, and it is where authorization bugs hide. - Acting as the user.
<identity impersonate="true" />,WindowsIdentity.Impersonate, or a SQL connection string usingIntegrated Security=SSPIwhere the end user's token reaches SQL Server or a file share. This is the hard bucket. Kerberos delegation does not follow a user into a token-based world.
Also check the IIS configuration, because the web.config is often not the whole truth:
Import-Module WebAdministration
Get-WebConfiguration -Filter /system.webServer/security/authentication/* `
-PSPath 'IIS:\Sites\LegacyApp' |
Select-Object SectionPath, enabled
Record the answer in three numbers: distinct identity call sites, distinct AD groups checked, and whether end-user impersonation reaches a second hop. A system with zero impersonation and four groups is a two-week identity slice. One that delegates the user's token to SQL Server for row-level security is a different conversation, and you want to have it before you quote.
Step 2: Get the account mapping right before touching code
The legacy app knows users as CONTOSO\jsmith. Entra ID will hand the new slice a token whose stable subject is an object ID (a GUID), plus a UPN like jsmith@contoso.com. Those are not the same string, and your audit table is full of the first one.
Build the mapping table once, in the database, and let both sides read it:
CREATE TABLE app_user_map (
sam_account_name nvarchar(128) NOT NULL PRIMARY KEY, -- 'CONTOSO\jsmith'
entra_object_id uniqueidentifier NOT NULL UNIQUE,
upn nvarchar(256) NOT NULL,
disabled_at datetime2 NULL
);
Populate it from the directory rather than by hand. If the tenant is hybrid with Entra Connect, the on-premises objects carry onPremisesSamAccountName and you can pull both keys from one place using the Microsoft Graph PowerShell SDK:
Connect-MgGraph -Scopes 'User.Read.All'
Get-MgUser -All -Property Id,UserPrincipalName,OnPremisesSamAccountName,AccountEnabled |
Where-Object { $_.OnPremisesSamAccountName } |
Select-Object Id, UserPrincipalName, OnPremisesSamAccountName, AccountEnabled |
Export-Csv .\user-map.csv -NoTypeInformation
Two things to check before you trust it, both of which we have been bitten by:
- Rows with no match. Service accounts, shared logins, and people who left in 2014 but whose name is still on 40,000 audit rows. Leave them in the map with
disabled_atset; do not delete history's keys. - Reused SAM names. Rare, but it happens after a merger. If
sam_account_namewill not go unique, you have a data problem that identity cannot fix, and the audit trail was already ambiguous.
Do this step before writing any authentication code. It is boring, it is a SQL table, and it is the thing that makes the rest reversible.
Step 3: Put the new slice on Entra ID
Register an application in the tenant (the customer's IT does this; you should not be creating tenant objects), then wire the slice with Microsoft.Identity.Web. The ASP.NET Core sign-in documentation covers the registration screens.
builder.Services
.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApp(builder.Configuration.GetSection("AzureAd"));
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("ApClerk", p => p.RequireRole("AP-Clerks"));
});
Ask for app roles or group claims deliberately. Emitting every group a user belongs to will, in a real company, overflow the token and Entra will replace the claims with a Graph link you then have to call. App roles assigned on the enterprise application are the cleaner shape, and they force someone to write down which roles the app actually has — which your bucket-two inventory just produced.
Then translate the token into the identity the application already understands, rather than spraying GUIDs through the business code:
public sealed class LegacyIdentityResolver(AppDbContext db)
{
// Returns 'CONTOSO\jsmith' for a signed-in Entra user, or null.
public async Task<string?> ResolveAsync(ClaimsPrincipal user, CancellationToken ct)
{
var oid = user.FindFirstValue("http://schemas.microsoft.com/identity/claims/objectidentifier");
if (!Guid.TryParse(oid, out var objectId)) return null;
return await db.UserMap
.Where(u => u.EntraObjectId == objectId && u.DisabledAt == null)
.Select(u => u.SamAccountName)
.SingleOrDefaultAsync(ct);
}
}
Now the new slice writes the same updated_by value the old app writes. Reports that group by user keep working across the boundary. That is the whole trick, and it is worth more than it looks: it means you can move one screen at a time without splitting the audit trail in half.
Step 4: Keep both halves signed in during the migration
While the strangler runs, a user goes back and forth between the legacy app and new slices behind the routing shim. Two authentication systems, one person, and a hard rule: exactly one of them is authoritative at a time.
The sequence that has worked for us:
- Legacy authoritative, new slice piggybacks. The old app keeps Windows auth. The shim forwards the authenticated user to the slice on a signed internal header, over a connection only the shim can open. The slice trusts that header only from the shim's address, and never from the internet. This gets the first slices live without an identity project.
- New slice authoritative, legacy follows. Once a slice owns sign-in through Entra, the old app stops doing Windows auth for browsers and reads an identity the shim sets. Practically, that means the legacy app moves to forms authentication against a ticket the new stack issues; we covered the cookie-sharing mechanics in moving Forms Authentication and SQL Membership onto .NET 8.
- Legacy retired. Windows auth goes away with the last page.
The failure mode to avoid is running step 1 and step 2 at once, where each side thinks the other is the source of truth. You get intermittent logouts that nobody can reproduce, because they depend on which URL the user hit first that morning.
Test the boundary explicitly — one test per direction, running against the shim:
[Fact]
public async Task Slice_RejectsIdentityHeader_WhenNotFromShim()
{
var direct = new HttpClient { BaseAddress = SliceDirectUrl };
direct.DefaultRequestHeaders.Add("X-Legacy-User", "CONTOSO\\admin");
var response = await direct.GetAsync("/orders");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
If that test is not in the suite, the header is an authentication bypass with a friendly name.
Step 5: Deal with the second hop
Back to bucket three. If the legacy app impersonates the end user to reach SQL Server or a file share, Entra tokens do not carry that capability to an on-premises resource. Three outcomes, in the order we prefer them:
- Move the authorization into the application. Most row-level security implemented through SQL logins was a workaround for not having an authorization model. The new slice connects as one managed identity or service account and filters in code, with the filter under test. More work, better system.
- Keep the hop, move the boundary. The legacy module keeps running with its own service account and gets fronted by an API the slice calls. The file share stays where it is. This is the agent access layer shape applied to identity.
- Keep Kerberos on an island. Some reporting tools genuinely need constrained delegation. Leave that one server domain-joined, scope what it can reach, and write down the date it gets revisited. Say that out loud rather than pretending it migrated.
What this does not solve
Entra ID on the new slice does not make the legacy app compliant with conditional access. Until the old front door is behind the new sign-in, a user on an unmanaged device can still reach the WebForms pages the way they always have. If the audit finding that started this project is about MFA on the line-of-business system, the honest sequence is shim first, sign-in second, screens third — not screens first.
It also does not delete your dependence on the domain. Service accounts running batch jobs, SQL Agent, scheduled tasks and the build server are a separate inventory with a separate plan. The overnight batch in particular usually runs as a domain account nobody can identify; that is worth a look while you are in here, and it is the subject of migrating the overnight batch.
Start with step 1. Three numbers — identity call sites, groups checked, impersonation yes or no — turn "we need to move off Windows auth" into an estimate somebody can argue with.