AD FS was the right answer in 2014 and it is technical debt in 2026: a Windows Server farm, a WAP DMZ tier, certificates that expire at 2 a.m., and claims rules written in a language only one person on the team still reads. The destination is Entra ID handling authentication directly, with the AD FS estate gone. The trap is treating this as a flag flip on the domain - it is a migration with a reversible, per-cohort cutover so you can prove each step before the next. This runbook walks the full path: inventory, authentication method, Staged Rollout cohorts, translating claims issuance rules into claims mapping policies, moving relying parties to enterprise apps, reproducing access control in Conditional Access, the domain conversion, and the decommission.
I assume Hybrid Identity Administrator plus Application Administrator, Entra Connect already syncing the relevant forests, and Entra ID P1 (P2 if you want risk-based Conditional Access).
1. Assess the AD FS estate: relying parties, claims rules, access-control policies
The single most expensive mistake is discovering a relying party in production after you decommissioned the farm. Pull everything from AD FS as data first.
# On a primary AD FS server. Export every relying party trust with its rules.
$rps = Get-AdfsRelyingPartyTrust
$rps | Select-Object Name, Identifier, Enabled, WSFedEndpoint, `
@{n='SamlACS';e={$_.SamlEndpoints.Location}}, SignatureAlgorithm |
Export-Csv C:\adfs-migration\relying-parties.csv -NoTypeInformation
# Dump the claims issuance rules and access-control policy per RP, as code to diff later
foreach ($rp in $rps) {
$safe = ($rp.Name -replace '[^\w\-]','_')
$rp.IssuanceTransformRules | Out-File "C:\adfs-migration\rules\$safe.issuance.txt"
$rp.IssuanceAuthorizationRules | Out-File "C:\adfs-migration\rules\$safe.authz.txt"
$rp.AccessControlPolicyName | Out-File "C:\adfs-migration\rules\$safe.acp.txt"
}
# Custom claim descriptions, endpoints, and the farm's signing/token-decrypt certs
Get-AdfsClaimDescription | Export-Csv C:\adfs-migration\claim-descriptions.csv -NoTypeInformation
Get-AdfsCertificate | Select-Object CertificateType, Thumbprint, NotAfter, IsPrimary |
Export-Csv C:\adfs-migration\certs.csv -NoTypeInformation
Microsoft ships AD FS migration scripts (the ADFSToAADAppMigration / ADFSAADMigrationUtils module) that score each RP’s automatic-migration readiness. Use it for a triage list, but anything with custom claim rules or an unusual NameID needs a human.
Bucket every relying party into four lanes: (1) gallery app - migrate to the first-party gallery template; (2) OIDC-capable - re-platform onto OpenID Connect and retire SAML; (3) SAML/WS-Fed, keep the protocol - federate as a non-gallery enterprise app; (4) cannot move - smart-card-only, a third-party MFA adapter with no cloud equivalent, or a legacy WS-Trust client. Lane 4 decides whether you fully decommission or keep a minimal AD FS island.
Two findings matter most before you commit: RPs using non-standard NameID formats, and any RP whose IssuanceAuthorizationRules is more than “permit all” - those become Conditional Access work in step 6.
2. Choose the target: PHS or PTA, and the role of Seamless SSO
Authentication after AD FS means cloud authentication. Two supported methods:
| Method | Where the password is validated | On-prem dependency at sign-in | When to pick it |
|---|---|---|---|
| Password Hash Sync (PHS) | In the cloud, against a synced hash-of-a-hash | None | The default. Survives an on-prem outage. |
| Pass-through Authentication (PTA) | On-prem DC via lightweight agents | Live agent + reachable DC | “Passwords never leave the building” mandates |
Default to PHS even if compliance pushes you toward PTA: Staged Rollout, leaked-credential detection (Entra ID Protection), and instant failover all depend on it. If you must run PTA, deploy at least three agents on separate hosts before cutover - PTA reintroduces the exact on-prem runtime dependency you are trying to delete.
Enable PHS now, while still federated. It changes nothing about how federated users sign in but pre-stages the hashes so a cohort can flip to cloud auth instantly.
# Confirm PHS is on for the connector even while the domain is still Federated
$c = Get-ADSyncConnector | Where-Object { $_.Type -eq 'Extensible2' }
Get-ADSyncAADPasswordSyncConfiguration -SourceConnector $c.Name # Enabled should be True
Seamless SSO is orthogonal: it silently signs in domain-joined, corporate-network users via Kerberos - the thing AD FS did for you on the corp network. Enable it before cutover so the experience does not regress the moment a cohort leaves federation. It creates the AZUREADSSOACC computer object, whose Kerberos key must be rolled at least every 30 days.
3. Use Staged Rollout to migrate user cohorts off federation safely
Staged Rollout makes this reversible. It moves selected groups to cloud authentication (PHS or PTA) while the domain remains Federated: those users authenticate against Entra, everyone else still goes to AD FS. You validate one cohort, then expand - no big-bang flip, and rollback is removing a group.
Prerequisites the portal will not always shout at you: PHS (or PTA) configured, Seamless SSO on, and security groups of at most 50,000 users with direct membership - nested groups are not honored.
Enable it under Entra Connect > Connect Sync > Staged Rollout of cloud authentication in the portal, or drive it through Microsoft Graph as a featureRolloutPolicy:
# Create a Staged Rollout policy for Password Hash Sync
az rest --method POST \
--url "https://graph.microsoft.com/v1.0/policies/featureRolloutPolicies" \
--headers "Content-Type=application/json" \
--body '{
"displayName": "PHS Staged Rollout - Wave 1",
"feature": "passwordHashSync",
"isEnabled": true,
"isAppliedToOrganization": false
}'
# Add a pilot security group to the policy (appliesTo references the group object)
az rest --method POST \
--url "https://graph.microsoft.com/v1.0/policies/featureRolloutPolicies/<policyId>/appliesTo/\$ref" \
--headers "Content-Type=application/json" \
--body '{ "@odata.id": "https://graph.microsoft.com/v1.0/directoryObjects/<groupId>" }'
The feature value is passwordHashSync, passThroughAuthentication, or seamlessSso. Run waves - a 20-user IT cohort, then a non-critical business unit, then broader - and confirm in the sign-in logs that each cohort authenticates in the cloud, not via AD FS, before expanding.
// Entra sign-in logs: are Staged Rollout users hitting the cloud, not AD FS?
SigninLogs
| where TimeGenerated > ago(1d)
| extend authDetail = tostring(parse_json(AuthenticationProcessingDetails))
| where UserPrincipalName in ("pilot1@contoso.com","pilot2@contoso.com")
| project TimeGenerated, UserPrincipalName, AppDisplayName,
ResultType, AuthenticationRequirement, authDetail
| order by TimeGenerated desc
Staged Rollout migrates user authentication, not applications. A user in a rollout group still reaches a SAML relying party through AD FS until you move that RP (steps 4-5). Sequence it: prove cloud auth for the cohort, migrate the apps, then convert the domain - do not jump ahead because a pilot looked clean.
4. Translate AD FS claims issuance rules into Entra claims mapping and policies
This is the hard part. AD FS uses the claims rule language (acceptance and issuance transform rules over a claims pipeline). Entra has no equivalent language - it has claims mapping policies plus claim transformations and directory extensions per service principal. You are not porting syntax, you are re-expressing intent.
A typical AD FS issuance rule emitting an email NameID and a UPN claim:
@RuleName = "Email as NameID and UPN"
c:[Type == "http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname"]
=> issue(
store = "Active Directory",
types = ("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier",
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn"),
query = ";mail,userPrincipalName;{0}", param = c.Value);
In Entra the equivalent is the app’s Attributes & Claims blade, or for advanced cases a claimsMappingPolicy bound to the service principal. The default NameID is UPN; to emit mail as NameID and add UPN as a separate claim:
{
"definition": [
"{\"ClaimsMappingPolicy\":{\"Version\":1,\"IncludeBasicClaimSet\":\"true\",\"ClaimsSchema\":[{\"Source\":\"user\",\"ID\":\"mail\",\"SamlClaimType\":\"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier\"},{\"Source\":\"user\",\"ID\":\"userprincipalname\",\"SamlClaimType\":\"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn\"}]}}"
],
"displayName": "billing-saml-claims",
"isOrganizationDefault": false
}
# Create the policy, then assign it to the app's service principal
POLICY_ID=$(az rest --method POST \
--url "https://graph.microsoft.com/v1.0/policies/claimsMappingPolicies" \
--headers "Content-Type=application/json" \
--body @billing-claims.json --query id -o tsv)
az rest --method POST \
--url "https://graph.microsoft.com/v1.0/servicePrincipals/<spId>/claimsMappingPolicies/\$ref" \
--headers "Content-Type=application/json" \
--body "{ \"@odata.id\": \"https://graph.microsoft.com/v1.0/policies/claimsMappingPolicies/$POLICY_ID\" }"
Mapping table for the rules you will actually hit:
| AD FS construct | Entra equivalent |
|---|---|
LDAP attribute -> claim (store = "Active Directory") |
Source user, an attribute ID, mapped to a SamlClaimType |
| Constant / literal issuance | Transformation with a constant input, or a fixed value claim |
RegexReplace / string manipulation |
Claim transformation (Join, ExtractMailPrefix, ToLowercase, RegexReplace, etc.) |
| Group membership -> role claim | App roles assigned to groups, emitted as the roles claim |
| Custom claim type | Custom claim with the original URI as SamlClaimType |
Synced custom attribute (e.g. employeeId) |
Directory extension synced by Entra Connect, used as the claim source |
Two rules cause most post-cutover breakage. NameID mismatch: if AD FS emitted
objectId(default) or on-prem group names (which requires syncing the right attribute).
The SAML SSO flow attaches the policy to the service principal for you, so per-app claim customization takes effect without hand-managing signing keys.
5. Migrate SAML and WS-Fed relying parties to enterprise applications
Move the relying parties one lane at a time. Gallery apps are easiest: add from the gallery, configure SSO with the SP’s metadata, inherit a maintained connector. Non-gallery SAML/WS-Fed apps are instantiated from the generic SAML template and configured by metadata exchange.
# Instantiate a non-gallery SAML enterprise app from the generic template
az rest --method POST \
--url "https://graph.microsoft.com/v1.0/applicationTemplates/8adf8e6e-67b2-4cf2-a259-e3dc5476c621/instantiate" \
--headers "Content-Type=application/json" \
--body '{"displayName": "acme-billing-saml"}'
The contract fields must match what AD FS published so the SP needs no change beyond trusting a new IdP: Identifier (Entity ID) = the RP Identifier (the assertion audience must equal this); Reply URL (ACS) = the SAML SamlEndpoints.Location (WS-Fed apps use WSFedEndpoint); plus Sign-on URL / RelayState for IdP-initiated or deep-link flows.
Then hand the SP Entra’s app-specific IdP metadata so it can validate the new signature, and import the new signing certificate:
https://login.microsoftonline.com/<tenant-id>/federationmetadata/2007-06/federationmetadata.xml?appid=<application-id>
Sequence the SP change to the moment of app cutover. While AD FS still serves an RP, do not point the SP at Entra; once the SP trusts Entra, that app no longer flows through AD FS - independent of any Staged Rollout group. App migration and user-auth migration are two switches; flip apps deliberately, one by one, with a rollback (re-point the SP at AD FS) ready for each.
WS-Fed RPs map to enterprise apps too (Entra supports WS-Fed as an SSO mode), but a WS-Trust client that cannot speak SAML 2.0 or OIDC is lane 4 - possibly your reason to keep a minimal AD FS island.
6. Reproduce access-control policies with Conditional Access equivalents
AD FS IssuanceAuthorizationRules and Access Control Policies (“permit intranet only”, “require MFA from extranet”, “permit specific groups”) do not migrate as data - they become Conditional Access. Map the intent:
| AD FS access-control intent | Conditional Access equivalent |
|---|---|
| Permit specific AD groups only | CA assignment: include those groups; block others / assign-to-all-and-exclude |
| Require MFA from extranet | CA grant: require MFA, scoped by network location (named locations) |
| Permit only from intranet IP ranges | CA condition: locations, block outside trusted IPs |
| Device-bound access (registered/compliant) | CA grant: require compliant or Hybrid Azure AD joined device |
| Per-RP authorization | CA policy scoped to that cloud app (the enterprise app) |
A representative policy: “this app requires MFA except from the trusted corporate network”:
{
"displayName": "CA200 - Billing app requires MFA off-network",
"state": "enabledForReportingButNotEnforced",
"conditions": {
"applications": { "includeApplications": ["<billing-app-appId>"] },
"users": { "includeGroups": ["<billing-users-groupId>"] },
"locations": {
"includeLocations": ["All"],
"excludeLocations": ["<trusted-corp-named-location-id>"]
}
},
"grantControls": {
"operator": "OR",
"builtInControls": ["mfa"]
}
}
Always create these in report-only mode first (
enabledForReportingButNotEnforced). CA decisions surface in the sign-in logs’ Conditional Access tab - run the cohort through report-only, confirm the right policies would apply, then enforce. A missed authorization rule that silently lets everyone into a previously restricted app is a security regression, not just a bug.
The AD FS “intranet only” pattern is not a single Entra toggle - it is a CA policy referencing named locations, so enumerate your egress IPs as a named location first or you lock out on-network users.
7. Cutover: convert domains from federated to managed and monitor sign-ins
Once a domain’s users are validated under Staged Rollout and its relying parties are moved, convert the domain from federated to managed. It feels irreversible (it is reversible, but it re-touches every federated user), so do it per domain, off-hours, with a rollback rehearsed.
Use the Microsoft Graph PowerShell SDK - the legacy MSOnline Set-MsolDomainAuthentication path is deprecated and being retired.
Connect-MgGraph -Scopes "Domain.ReadWrite.All","Directory.AccessAsUser.All"
# Confirm current state before touching anything
Get-MgDomain -DomainId contoso.com | Select-Object Id, AuthenticationType, IsVerified
# Convert federated -> managed (cloud authentication via PHS/PTA)
Update-MgDomain -DomainId contoso.com -AuthenticationType "Managed"
# Verify it flipped
(Get-MgDomain -DomainId contoso.com).AuthenticationType # expect: Managed
On conversion, users in a Staged Rollout group for that domain are removed from the rollout (the domain is now managed, a superset). Conversion is a control-plane operation; existing sessions and tokens stay valid until they expire, so the experience change is gradual.
Plan for the MFA-registration nuance before converting. Federated users may never have registered cloud MFA (AD FS or a third-party adapter handled it). After conversion, MFA is enforced by Conditional Access against Entra-registered methods - so drive registration before cutover (Staged Rollout is the window) or users hit an MFA wall at first managed sign-in. Pre-register via a CA “register security information” flow during the pilot.
Watch the sign-in logs closely for the first hours after conversion:
// Post-conversion health: surface failures by app and result code
SigninLogs
| where TimeGenerated > ago(2h)
| summarize Total=count(),
Failures=countif(ResultType != 0),
DistinctUsers=dcount(UserPrincipalName)
by AppDisplayName, ResultType, ResultDescription
| where Failures > 0
| order by Failures desc
A spike in ResultType 50126 (invalid credentials) right after conversion usually means PHS was not actually flowing for some users - investigate before the next domain. 50076/50079 indicate MFA registration gaps, exactly what the pre-registration step prevents.
8. Decommission AD FS, WAP, and clean up DNS, certificates, and trusts
Do not power off the farm the day after conversion. Leave it idle for a soak period (a week or two) so any forgotten relying party announces itself in the AD FS logs, and watch the request rate drop to zero before removing anything.
# On AD FS: is anything still authenticating? Watch this trend to zero before decommission.
Get-WinEvent -LogName "AD FS/Admin" -MaxEvents 200 |
Where-Object { $_.Id -in 299,324,412 } |
Select-Object TimeCreated, Id, Message | Format-Table -Wrap
When traffic is genuinely zero, decommission in dependency order - DMZ first, trust last:
- WAP (DMZ) tier first. Remove the Web Application Proxy nodes from the load balancer, then the servers. They are the internet-facing attack surface; kill them first.
- Repoint or retire DNS. The
sts.contoso.com/adfs.contoso.comA records (internal and external) and anyenterpriseregistrationrecords. Lower TTL ahead of time, then remove. - Remove the AD FS role from the internal farm nodes, then delete the configuration database (WID or SQL).
- Certificates and trusts. Retire the token-signing and token-decrypting certificates, remove the SSL binding, and revoke if they were dedicated to AD FS. Remove device registration artifacts only if you have moved device registration to Entra - do not orphan Hybrid Azure AD Join.
- Service account and SPNs. De-provision the AD FS gMSA/service account and clean up the
host/sts.contoso.comSPNs so they cannot be reused.
# Confirm Entra no longer thinks any domain is federated before you wipe the farm
Get-MgDomain | Where-Object { $_.AuthenticationType -eq 'Federated' } |
Select-Object Id, AuthenticationType
# Empty result == safe to decommission AD FS for authentication purposes
The order matters: device registration and certain hybrid-join flows can ride on the AD FS device registration service. Confirm Hybrid Azure AD Join is healthy in the Entra device inventory before tearing down trusts, or you silently break device-based Conditional Access for the whole fleet.
Enterprise scenario
A financial-services platform team ran AD FS for 40-odd relying parties and was three months into a “flip to managed” plan that had stalled twice. The blocker: their flagship SAML payments app emitted the user’s on-prem sAMAccountName as the NameID (an AD FS rule querying ;sAMAccountName;{0}), and the SP keyed every payment record to that value. A naive cutover would make Entra emit UPN, the SP would create shadow accounts, and every payment mandate would orphan - a customer-visible failure. They were about to keep AD FS indefinitely.
The constraint was real: the NameID had to stay the legacy sAMAccountName, which is not a default Entra claim source. The fix was a directory-extension-backed claims mapping policy. They had Entra Connect sync sAMAccountName into a directory extension, then bound a policy emitting that extension as the NameID with the original claim URI - byte-for-byte what AD FS produced.
{
"definition": [
"{\"ClaimsMappingPolicy\":{\"Version\":1,\"IncludeBasicClaimSet\":\"true\",\"ClaimsSchema\":[{\"Source\":\"user\",\"ExtensionID\":\"extension_<appId>_onPremSamAccountName\",\"SamlClaimType\":\"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier\",\"SamlNameIdentifierFormat\":\"urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified\"}]}}"
],
"displayName": "payments-nameid-sam",
"isOrganizationDefault": false
}
They migrated that one app under a Staged Rollout cohort of ten back-office users, ran a synthetic payment end to end, confirmed the SP matched the existing record by NameID, then expanded. The conversion stuck for three months completed in one off-hours window once NameID parity was proven. Lesson: the hard part is almost never the domain flip - it is one or two relying parties with a load-bearing claim that has no default Entra source. Solve those with directory extensions and a claims mapping policy on a tiny cohort, and the rest is mechanical.
Verify
Prove each layer before moving to the next domain.
# 1. Authentication method actually flowing in the cloud
$c = Get-ADSyncConnector | Where-Object { $_.Type -eq 'Extensible2' }
Get-ADSyncAADPasswordSyncConfiguration -SourceConnector $c.Name # Enabled = True
# 2. Domain state is what you intend (Managed after cutover, Federated before)
Get-MgDomain | Select-Object Id, AuthenticationType, IsVerified | Sort-Object Id
# 3. Staged Rollout policies and their target groups
Get-MgPolicyFeatureRolloutPolicy | Select-Object DisplayName, Feature, IsEnabled
Then validate from the user’s chair: a cutover user signs in at https://myapps.microsoft.com, opens a migrated SAML app, and lands authenticated with the same identity (NameID) the SP recorded under AD FS. On a domain-joined corp machine they should not be prompted (Seamless SSO). Confirm in SigninLogs that sign-in is no longer federated and the expected Conditional Access policies applied, and that the AD FS request rate is zero before any teardown.
Checklist
Pitfalls and next steps
- NameID drift. The number-one cause of account orphaning. Pin NameID per app to exactly what AD FS emitted; never let it default to UPN for an app that keyed on
mailorsAMAccountName. - Skipping report-only Conditional Access. Enforce the CA equivalent of an AD FS authorization rule without a report-only pass and you either lock out legitimate users or silently expose a restricted app.
- MFA registration gap. Federated users who never registered cloud MFA hit a wall at first managed sign-in. Pre-register during the Staged Rollout window.
- Tearing down trusts before checking Hybrid Join. Device registration can ride on AD FS. Confirm device-based Conditional Access still works against Entra before removing trusts and DNS.
- Treating the domain flip as the migration. It is the last 5%; the work is inventory, claim parity, and per-app SP cutover. The
Update-MgDomaincall is anticlimactic when the groundwork is done.
Next, retire lane-4 dependencies methodically (replace third-party MFA adapters with Entra MFA / authentication strengths, re-platform WS-Trust clients onto OIDC), wire Entra Connect Health and sign-in failure alerts into your incident channel, and fold the new enterprise apps into a persona-based Conditional Access framework so authentication and authorization are governed as one design.