Identity Azure

Migrating from AD FS to Entra ID Authentication: Staged Cutover with PHS, Staged Rollout, and Claims-Rule Mapping

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 mail and Entra defaults to UPN, the SP creates a new account or rejects the assertion - pin NameID explicitly per app. Group/role claims: AD FS often emitted group SIDs or names the app authorized against; reproduce that with app roles or a groups claim, deciding whether to emit 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:

  1. 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.
  2. Repoint or retire DNS. The sts.contoso.com / adfs.contoso.com A records (internal and external) and any enterpriseregistration records. Lower TTL ahead of time, then remove.
  3. Remove the AD FS role from the internal farm nodes, then delete the configuration database (WID or SQL).
  4. 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.
  5. Service account and SPNs. De-provision the AD FS gMSA/service account and clean up the host/sts.contoso.com SPNs 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

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.

Entra IDAD FSFederationMigrationStaged Rollout

Comments

Keep Reading