Moving users from email/password to SSO doesn’t have to mean duplicate accounts or messy migrations. With custom policies, you can automatically link SSO identities to existing users, set a permanent migration flag, and block legacy password flows—all without any user friction beyond a single SSO sign-in.
Why This Matters
As organizations modernize authentication, many shift toward Single Sign-On (SSO) using providers like Microsoft EntraID.
But if you already have users in Azure AD B2C using local accounts (email + password), the transition isn’t straightforward.
You’ll run into:
- Duplicate identities when users sign in with SSO using the same email
- No clean migration path for existing users
- Security gaps if password sign-in remains available after SSO
- Confusing UX if password reset still allowed for SSO users
The Goal
A seamless, secure transition where:
- Users keep a single identity (objectId)
- SSO is automatically linked to existing accounts
- Password-based flows are permanently disabled after migration
- Non-migrated users continue normal local flows (including Forgot Password)
- No manual migration or user intervention is required
The Pattern: “SSO Takeover”
This approach uses custom policies (Identity Experience Framework) to:
- Detect when a user signs in via SSO
- Check if a local account exists with the same email
- Automatically link the identities
- Set a flag: extension_ssoMigrated = true
- Enforce SSO-only access going forward
| Scenario | Outcome |
|---|---|
| Local user (not migrated) | ✅ Password sign-in works |
| Local user (not migrated) – Forgot Password | ✅ Allowed |
| First SSO login (existing user) | ✅ Account linked automatically |
| SSO-migrated user | ✅ SSO works |
| SSO-migrated user – password login | ❌ Blocked |
| SSO-migrated user – password reset | ❌ Blocked |
Key Building Blocks
1. Migration Flag: extension_ssoMigrated
A custom boolean attribute stored on the user object. This drives all decisions.
<ClaimType Id="extension_ssoMigrated">
<DataType>boolean</DataType>
</ClaimType>
2. Conditional blocking (only for migrated users)
A transformation enforces:
- If extension_ssoMigrated = true → block
- Otherwise → continue normally
This is applied in:
- Local sign-in
- Password reset
Using preconditions ensures:
- ❌ Migrated users are blocked
- ✅ Non-migrated users are NOT affected
3. Automatic Account Linking
During SSO login:
- Look up user by email
- If found → attach alternativeSecurityId
- Set extension_ssoMigrated = true
No duplication. No manual merge.
Simplified flow
- User clicks “Sign in with Microsoft”
- System checks existing SSO account
- If none → lookup by email
- If match → link + mark migrated
- Issue token
TrustFrameworkExtension.xml
<?xml version="1.0" encoding="utf-8" ?>
<TrustFrameworkPolicy
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
xmlns="http://schemas.microsoft.com/online/cpim/schemas/2013/06"
PolicySchemaVersion="0.3.0.0"
TenantId="yourtenant.onmicrosoft.com"
PolicyId="B2C_1A_TrustFrameworkExtensions"
PublicPolicyUri="http://yourtenant.onmicrosoft.com/B2C_1A_TrustFrameworkExtensions"
TenantObjectId="tenantID">
<BasePolicy>
<TenantId>yourtenant.onmicrosoft.com</TenantId>
<PolicyId>B2C_1A_TrustFrameworkLocalization</PolicyId>
</BasePolicy>
<BuildingBlocks>
<ClaimsSchema>
<ClaimType Id="extension_ssoMigrated">
<DisplayName>SSO Migrated</DisplayName>
<DataType>boolean</DataType>
<UserHelpText>Indicates if user migrated to SSO</UserHelpText>
</ClaimType>
<ClaimType Id="isForgotPassword">
<DisplayName>isForgotPassword</DisplayName>
<DataType>boolean</DataType>
<AdminHelpText>Whether the user has clicked Forgot your password</AdminHelpText>
</ClaimType>
</ClaimsSchema>
<ClaimsTransformations>
<ClaimsTransformation Id="AssertNotSsoMigrated" TransformationMethod="AssertBooleanClaimIsEqualToValue">
<InputClaims>
<InputClaim ClaimTypeReferenceId="extension_ssoMigrated" TransformationClaimType="inputClaim" />
</InputClaims>
<InputParameters>
<InputParameter Id="valueToCompareTo" DataType="boolean" Value="false" />
</InputParameters>
</ClaimsTransformation>
</ClaimsTransformations>
</BuildingBlocks>
<ClaimsProviders>
<ClaimsProvider>
<Domain>AADBSI</Domain>
<DisplayName>Sign in with Microsoft</DisplayName>
<TechnicalProfiles>
<TechnicalProfile Id="AADBSI-OpenIdConnect">
<DisplayName>Sign in with Microsoft</DisplayName>
<Description>Sign in with Microsoft</Description>
<Protocol Name="OpenIdConnect" />
<Metadata>
<Item Key="METADATA">https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration</Item>
<Item Key="client_id">4a84d062-21d7-4e96-8f80-c6b688e7127b</Item>
<Item Key="response_types">code</Item>
<Item Key="scope">openid profile</Item>
<Item Key="response_mode">form_post</Item>
<Item Key="HttpBinding">POST</Item>
<Item Key="UsePolicyInRedirectUri">false</Item>
<Item Key="DiscoverMetadataByTokenIssuer">true</Item>
<Item Key="ValidTokenIssuerPrefixes">https://sts.windows.net/,https://login.microsoftonline.com/</Item>
</Metadata>
<CryptographicKeys>
<Key Id="client_secret" StorageReferenceId="B2C_1A_Multitenancy" />
</CryptographicKeys>
<OutputClaims>
<OutputClaim ClaimTypeReferenceId="issuerUserId" PartnerClaimType="oid" />
<OutputClaim ClaimTypeReferenceId="tenantId" PartnerClaimType="tid" />
<OutputClaim ClaimTypeReferenceId="givenName" PartnerClaimType="given_name" />
<OutputClaim ClaimTypeReferenceId="surName" PartnerClaimType="family_name" />
<OutputClaim ClaimTypeReferenceId="displayName" PartnerClaimType="name" />
<OutputClaim ClaimTypeReferenceId="authenticationSource" DefaultValue="socialIdpAuthentication" AlwaysUseDefaultValue="true" />
<OutputClaim ClaimTypeReferenceId="identityProvider" PartnerClaimType="iss" />
<OutputClaim ClaimTypeReferenceId="email" PartnerClaimType="email" />
<OutputClaim ClaimTypeReferenceId="otherMails" PartnerClaimType="mail" />
</OutputClaims>
<OutputClaimsTransformations>
<OutputClaimsTransformation ReferenceId="CreateRandomUPNUserName" />
<OutputClaimsTransformation ReferenceId="CreateUserPrincipalName" />
<OutputClaimsTransformation ReferenceId="CreateAlternativeSecurityId" />
<OutputClaimsTransformation ReferenceId="CreateSubjectClaimFromAlternativeSecurityId" />
</OutputClaimsTransformations>
<UseTechnicalProfileForSessionManagement ReferenceId="SM-SocialLogin" />
</TechnicalProfile>
</TechnicalProfiles>
</ClaimsProvider>
<ClaimsProvider>
<DisplayName>Local Account SignIn</DisplayName>
<TechnicalProfiles>
<TechnicalProfile Id="login-NonInteractive">
<Metadata>
<Item Key="client_id">a37a58e7-e96a-4365-bb8e-169bee86dde1</Item>
<Item Key="IdTokenAudience">92114a5a-99df-40c8-8b08-228616b18c57</Item>
</Metadata>
<InputClaims>
<InputClaim ClaimTypeReferenceId="client_id" DefaultValue="a37a58e7-e96a-4365-bb8e-169bee86dde1" />
<InputClaim ClaimTypeReferenceId="resource_id" PartnerClaimType="resource" DefaultValue="92114a5a-99df-40c8-8b08-228616b18c57" />
</InputClaims>
</TechnicalProfile>
</TechnicalProfiles>
</ClaimsProvider>
<ClaimsProvider>
<DisplayName>Azure Active Directory - SSO Control</DisplayName>
<TechnicalProfiles>
<TechnicalProfile Id="AAD-Common">
<Metadata>
<Item Key="ApplicationObjectId">3cc7f330-2408-4999-ab68-b74d6feccdf1</Item>
<Item Key="ClientId">06c3fab4-3e2a-4d1d-9a78-8954da5d364f</Item>
</Metadata>
</TechnicalProfile>
<TechnicalProfile Id="AAD-UserReadUsingEmailAddress-Takeover">
<Metadata>
<Item Key="Operation">Read</Item>
<Item Key="RaiseErrorIfClaimsPrincipalDoesNotExist">false</Item>
</Metadata>
<IncludeInSso>false</IncludeInSso>
<InputClaims>
<InputClaim ClaimTypeReferenceId="email" PartnerClaimType="signInNames.emailAddress" Required="true" />
</InputClaims>
<OutputClaims>
<OutputClaim ClaimTypeReferenceId="objectId" />
<OutputClaim ClaimTypeReferenceId="extension_ssoMigrated" />
</OutputClaims>
<IncludeTechnicalProfile ReferenceId="AAD-Common" />
</TechnicalProfile>
<TechnicalProfile Id="AAD-LinkSSOToExistingUser">
<Metadata>
<Item Key="Operation">Write</Item>
<Item Key="RaiseErrorIfClaimsPrincipalDoesNotExist">true</Item>
</Metadata>
<IncludeInSso>false</IncludeInSso>
<InputClaims>
<InputClaim ClaimTypeReferenceId="objectId" Required="true" />
</InputClaims>
<PersistedClaims>
<PersistedClaim ClaimTypeReferenceId="objectId" />
<PersistedClaim ClaimTypeReferenceId="alternativeSecurityId" />
<PersistedClaim ClaimTypeReferenceId="extension_ssoMigrated" DefaultValue="true" />
</PersistedClaims>
<OutputClaims>
<OutputClaim ClaimTypeReferenceId="extension_ssoMigrated" DefaultValue="true" AlwaysUseDefaultValue="true" />
</OutputClaims>
<IncludeTechnicalProfile ReferenceId="AAD-Common" />
<UseTechnicalProfileForSessionManagement ReferenceId="SM-AAD" />
</TechnicalProfile>
<!-- When SSO creates a new user, also write email as signInNames.emailAddress.
This claims the email so any subsequent local signup with the same email
is blocked by RaiseErrorIfClaimsPrincipalAlreadyExists in
AAD-UserWriteUsingLogonEmail — preventing duplicate accounts.
The UserMessageIfClaimsPrincipalAlreadyExists on AAD-UserWriteUsingLogonEmail
override below surfaces the SSO-guidance message to the user. -->
<TechnicalProfile Id="AAD-UserWriteUsingAlternativeSecurityId">
<PersistedClaims>
<PersistedClaim ClaimTypeReferenceId="alternativeSecurityId" />
<PersistedClaim ClaimTypeReferenceId="userPrincipalName" />
<PersistedClaim ClaimTypeReferenceId="mailNickName" DefaultValue="unknown" />
<PersistedClaim ClaimTypeReferenceId="displayName" DefaultValue="unknown" />
<PersistedClaim ClaimTypeReferenceId="otherMails" />
<PersistedClaim ClaimTypeReferenceId="givenName" />
<PersistedClaim ClaimTypeReferenceId="surname" />
<PersistedClaim ClaimTypeReferenceId="email" PartnerClaimType="signInNames.emailAddress" />
</PersistedClaims>
</TechnicalProfile>
<!-- Override to surface a clear SSO-guidance message when local signup is blocked
because the email is already claimed by an SSO-first account.
UserMessageIfClaimsPrincipalAlreadyExists must be on the AAD write TP. -->
<TechnicalProfile Id="AAD-UserWriteUsingLogonEmail">
<Metadata>
<Item Key="UserMessageIfClaimsPrincipalAlreadyExists">An account already exists for this email via SSO. Please sign in using SSO instead of creating a local account.</Item>
</Metadata>
</TechnicalProfile>
<!-- Reads extension_ssoMigrated flag by objectId after password validation.
Only runs after login-NonInteractive has set objectId.
Used to conditionally block sign-in/reset for SSO-migrated users. -->
<TechnicalProfile Id="AAD-ReadSsoMigratedFlag">
<Metadata>
<Item Key="Operation">Read</Item>
<Item Key="RaiseErrorIfClaimsPrincipalDoesNotExist">false</Item>
</Metadata>
<IncludeInSso>false</IncludeInSso>
<InputClaims>
<InputClaim ClaimTypeReferenceId="objectId" Required="true" />
</InputClaims>
<OutputClaims>
<OutputClaim ClaimTypeReferenceId="extension_ssoMigrated" />
</OutputClaims>
<IncludeTechnicalProfile ReferenceId="AAD-Common" />
</TechnicalProfile>
</TechnicalProfiles>
</ClaimsProvider>
<!-- Inline password reset: surfaces as the ForgotPasswordExchange ClaimsProviderSelection
inside the CombinedSignInAndSignUp step. This keeps the forgot-password flow entirely
within B2C — no AADB2C90118 error is returned to the application. -->
<ClaimsProvider>
<DisplayName>Local Account Password Reset</DisplayName>
<TechnicalProfiles>
<TechnicalProfile Id="ForgotPassword">
<DisplayName>Forgot your password?</DisplayName>
<Protocol Name="Proprietary" Handler="Web.TPEngine.Providers.ClaimsTransformationProtocolProvider, Web.TPEngine, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" />
<OutputClaims>
<OutputClaim ClaimTypeReferenceId="isForgotPassword" DefaultValue="true" AlwaysUseDefaultValue="true" />
</OutputClaims>
<UseTechnicalProfileForSessionManagement ReferenceId="SM-Noop" />
</TechnicalProfile>
</TechnicalProfiles>
</ClaimsProvider>
<ClaimsProvider>
<DisplayName>SSO Migration Check</DisplayName>
<TechnicalProfiles>
<TechnicalProfile Id="ThrowSsoMigratedError">
<DisplayName>Block local password journeys</DisplayName>
<Protocol Name="Proprietary" Handler="Web.TPEngine.Providers.ClaimsTransformationProtocolProvider, Web.TPEngine, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" />
<InputClaims>
<InputClaim ClaimTypeReferenceId="extension_ssoMigrated" />
</InputClaims>
<OutputClaims>
<OutputClaim ClaimTypeReferenceId="extension_ssoMigrated" />
</OutputClaims>
<OutputClaimsTransformations>
<OutputClaimsTransformation ReferenceId="AssertNotSsoMigrated" />
</OutputClaimsTransformations>
</TechnicalProfile>
</TechnicalProfiles>
</ClaimsProvider>
<ClaimsProvider>
<DisplayName>Local Account</DisplayName>
<TechnicalProfiles>
<TechnicalProfile Id="SelfAsserted-LocalAccountSignin-Email">
<Metadata>
<!-- Carry over required base metadata so the TP renders and validates correctly -->
<Item Key="SignUpTarget">SignUpWithLogonEmailExchange</Item>
<Item Key="setting.operatingMode">Email</Item>
<Item Key="setting.forgotPasswordLinkOverride">ForgotPasswordExchange</Item>
<Item Key="ContentDefinitionReferenceId">api.localaccountsignin</Item>
<Item Key="IncludeClaimResolvingInClaimsHandling">true</Item>
<Item Key="UserMessageIfClaimsTransformationBooleanValueIsNotEqual">This account has been migrated to SSO. Please sign in with SSO instead.</Item>
</Metadata>
<IncludeInSso>false</IncludeInSso>
<InputClaims>
<InputClaim ClaimTypeReferenceId="signInName" DefaultValue="{OIDC:LoginHint}" AlwaysUseDefaultValue="true" />
</InputClaims>
<OutputClaims>
<OutputClaim ClaimTypeReferenceId="signInName" Required="true" />
<OutputClaim ClaimTypeReferenceId="password" Required="true" />
<OutputClaim ClaimTypeReferenceId="objectId" />
<OutputClaim ClaimTypeReferenceId="authenticationSource" />
<OutputClaim ClaimTypeReferenceId="extension_ssoMigrated" />
</OutputClaims>
<ValidationTechnicalProfiles>
<!-- Step 1: validate credentials, sets objectId -->
<ValidationTechnicalProfile ReferenceId="login-NonInteractive" />
<!-- Step 2: read the real extension_ssoMigrated flag from the directory.
RaiseErrorIfClaimsPrincipalDoesNotExist=false on the TP definition
means this silently returns nothing if the user or attribute is missing. -->
<ValidationTechnicalProfile ReferenceId="AAD-ReadSsoMigratedFlag" />
<!-- Step 3: block only users who have been migrated to SSO.
ClaimsExist guard ensures this VTP is skipped when extension_ssoMigrated
was never set (normal local users, extension attributes not configured). -->
<ValidationTechnicalProfile ReferenceId="ThrowSsoMigratedError">
<Preconditions>
<Precondition Type="ClaimsExist" ExecuteActionsIf="false">
<Value>extension_ssoMigrated</Value>
<Action>SkipThisValidationTechnicalProfile</Action>
</Precondition>
<Precondition Type="ClaimEquals" ExecuteActionsIf="false">
<Value>extension_ssoMigrated</Value>
<Value>True</Value>
<Action>SkipThisValidationTechnicalProfile</Action>
</Precondition>
</Preconditions>
</ValidationTechnicalProfile>
</ValidationTechnicalProfiles>
<UseTechnicalProfileForSessionManagement ReferenceId="SM-AAD" />
</TechnicalProfile>
<TechnicalProfile Id="LocalAccountDiscoveryUsingEmailAddress">
<Metadata>
<Item Key="UserMessageIfClaimsTransformationBooleanValueIsNotEqual">This account has been migrated to SSO. Password reset is not available. Please sign in with SSO.</Item>
</Metadata>
<OutputClaims>
<OutputClaim ClaimTypeReferenceId="email" PartnerClaimType="Verified.Email" Required="true" />
<OutputClaim ClaimTypeReferenceId="objectId" />
<OutputClaim ClaimTypeReferenceId="userPrincipalName" />
<OutputClaim ClaimTypeReferenceId="authenticationSource" />
<OutputClaim ClaimTypeReferenceId="extension_ssoMigrated" />
</OutputClaims>
<ValidationTechnicalProfiles>
<!-- Step 1: look up account by email, sets objectId -->
<ValidationTechnicalProfile ReferenceId="AAD-UserReadUsingEmailAddress" />
<!-- Step 2: read the real extension_ssoMigrated flag.
RaiseErrorIfClaimsPrincipalDoesNotExist=false on the TP definition
means this silently returns nothing if the user or attribute is missing. -->
<ValidationTechnicalProfile ReferenceId="AAD-ReadSsoMigratedFlag" />
<!-- Step 3: block password reset only for SSO-migrated users.
ClaimsExist guard ensures this VTP is skipped when extension_ssoMigrated
was never set. -->
<ValidationTechnicalProfile ReferenceId="ThrowSsoMigratedError">
<Preconditions>
<Precondition Type="ClaimsExist" ExecuteActionsIf="false">
<Value>extension_ssoMigrated</Value>
<Action>SkipThisValidationTechnicalProfile</Action>
</Precondition>
<Precondition Type="ClaimEquals" ExecuteActionsIf="false">
<Value>extension_ssoMigrated</Value>
<Value>True</Value>
<Action>SkipThisValidationTechnicalProfile</Action>
</Precondition>
</Preconditions>
</ValidationTechnicalProfile>
</ValidationTechnicalProfiles>
</TechnicalProfile>
<TechnicalProfile Id="LocalAccountWritePasswordUsingObjectId">
<UseTechnicalProfileForSessionManagement ReferenceId="SM-AAD" />
</TechnicalProfile>
</TechnicalProfiles>
</ClaimsProvider>
</ClaimsProviders>
<UserJourneys>
<UserJourney Id="CustomSignUpOrSignIn">
<OrchestrationSteps>
<OrchestrationStep Order="1" Type="CombinedSignInAndSignUp" ContentDefinitionReferenceId="api.signuporsignin">
<ClaimsProviderSelections>
<ClaimsProviderSelection ValidationClaimsExchangeId="LocalAccountSigninEmailExchange" />
<ClaimsProviderSelection TargetClaimsExchangeId="AzureCommon-AAD-Exchange" />
<ClaimsProviderSelection TargetClaimsExchangeId="ForgotPasswordExchange" />
</ClaimsProviderSelections>
<ClaimsExchanges>
<ClaimsExchange Id="LocalAccountSigninEmailExchange" TechnicalProfileReferenceId="SelfAsserted-LocalAccountSignin-Email" />
</ClaimsExchanges>
</OrchestrationStep>
<!-- Step 2: Sign-up / SSO / Forgot-password exchange.
CombinedSignInAndSignUp (Step 1) acts as the implicit ClaimsProviderSelection,
so this step is a ClaimsExchange directly — no separate selection step needed.
Every TargetClaimsExchangeId from Step 1 must have a matching exchange here:
• "Sign up now" → SignUpWithLogonEmailExchange
• "Sign in with Microsoft" → AzureCommon-AAD-Exchange
• "Forgot your password?" → ForgotPasswordExchange -->
<OrchestrationStep Order="2" Type="ClaimsExchange">
<Preconditions>
<Precondition Type="ClaimsExist" ExecuteActionsIf="true">
<Value>objectId</Value>
<Action>SkipThisOrchestrationStep</Action>
</Precondition>
</Preconditions>
<ClaimsExchanges>
<ClaimsExchange Id="SignUpWithLogonEmailExchange" TechnicalProfileReferenceId="LocalAccountSignUpWithLogonEmail" />
<ClaimsExchange Id="AzureCommon-AAD-Exchange" TechnicalProfileReferenceId="AADBSI-OpenIdConnect" />
<ClaimsExchange Id="ForgotPasswordExchange" TechnicalProfileReferenceId="ForgotPassword" />
</ClaimsExchanges>
</OrchestrationStep>
<!-- Steps 3-4: Inline forgot-password flow.
Two preconditions ensure this step ONLY runs when the user clicked
"Forgot your password?":
1. ClaimsExist guard — skips when isForgotPassword was never set
(signup, SSO, and local-sign-in flows).
2. ClaimEquals guard — skips when isForgotPassword exists but ≠ true. -->
<OrchestrationStep Order="3" Type="ClaimsExchange">
<Preconditions>
<Precondition Type="ClaimsExist" ExecuteActionsIf="false">
<Value>isForgotPassword</Value>
<Action>SkipThisOrchestrationStep</Action>
</Precondition>
<Precondition Type="ClaimEquals" ExecuteActionsIf="false">
<Value>isForgotPassword</Value>
<Value>true</Value>
<Action>SkipThisOrchestrationStep</Action>
</Precondition>
</Preconditions>
<ClaimsExchanges>
<ClaimsExchange Id="PasswordResetUsingEmailAddressExchange" TechnicalProfileReferenceId="LocalAccountDiscoveryUsingEmailAddress" />
</ClaimsExchanges>
</OrchestrationStep>
<OrchestrationStep Order="4" Type="ClaimsExchange">
<Preconditions>
<Precondition Type="ClaimsExist" ExecuteActionsIf="false">
<Value>isForgotPassword</Value>
<Action>SkipThisOrchestrationStep</Action>
</Precondition>
<Precondition Type="ClaimEquals" ExecuteActionsIf="false">
<Value>isForgotPassword</Value>
<Value>true</Value>
<Action>SkipThisOrchestrationStep</Action>
</Precondition>
</Preconditions>
<ClaimsExchanges>
<ClaimsExchange Id="NewCredentials" TechnicalProfileReferenceId="LocalAccountWritePasswordUsingObjectId" />
</ClaimsExchanges>
</OrchestrationStep>
<!-- Step 5: Read SSO user from directory by alternativeSecurityId -->
<OrchestrationStep Order="5" Type="ClaimsExchange">
<Preconditions>
<Precondition Type="ClaimEquals" ExecuteActionsIf="true">
<Value>isForgotPassword</Value>
<Value>true</Value>
<Action>SkipThisOrchestrationStep</Action>
</Precondition>
<Precondition Type="ClaimEquals" ExecuteActionsIf="true">
<Value>authenticationSource</Value>
<Value>localAccountAuthentication</Value>
<Action>SkipThisOrchestrationStep</Action>
</Precondition>
</Preconditions>
<ClaimsExchanges>
<ClaimsExchange Id="AADUserReadUsingAlternativeSecurityId" TechnicalProfileReferenceId="AAD-UserReadUsingAlternativeSecurityId-NoError" />
</ClaimsExchanges>
</OrchestrationStep>
<!-- Step 6: SSO takeover — look up existing local account by email -->
<OrchestrationStep Order="6" Type="ClaimsExchange">
<Preconditions>
<Precondition Type="ClaimEquals" ExecuteActionsIf="true">
<Value>authenticationSource</Value>
<Value>localAccountAuthentication</Value>
<Action>SkipThisOrchestrationStep</Action>
</Precondition>
<Precondition Type="ClaimsExist" ExecuteActionsIf="true">
<Value>objectId</Value>
<Action>SkipThisOrchestrationStep</Action>
</Precondition>
<Precondition Type="ClaimsExist" ExecuteActionsIf="false">
<Value>email</Value>
<Action>SkipThisOrchestrationStep</Action>
</Precondition>
</Preconditions>
<ClaimsExchanges>
<ClaimsExchange Id="ReadByEmail" TechnicalProfileReferenceId="AAD-UserReadUsingEmailAddress-Takeover" />
</ClaimsExchanges>
</OrchestrationStep>
<!-- Step 7: Link SSO identity to existing local account -->
<OrchestrationStep Order="7" Type="ClaimsExchange">
<Preconditions>
<Precondition Type="ClaimEquals" ExecuteActionsIf="true">
<Value>isForgotPassword</Value>
<Value>true</Value>
<Action>SkipThisOrchestrationStep</Action>
</Precondition>
<Precondition Type="ClaimsExist" ExecuteActionsIf="false">
<Value>objectId</Value>
<Action>SkipThisOrchestrationStep</Action>
</Precondition>
<Precondition Type="ClaimEquals" ExecuteActionsIf="true">
<Value>authenticationSource</Value>
<Value>localAccountAuthentication</Value>
<Action>SkipThisOrchestrationStep</Action>
</Precondition>
<Precondition Type="ClaimEquals" ExecuteActionsIf="true">
<Value>extension_ssoMigrated</Value>
<Value>True</Value>
<Action>SkipThisOrchestrationStep</Action>
</Precondition>
</Preconditions>
<ClaimsExchanges>
<ClaimsExchange Id="LinkSSO" TechnicalProfileReferenceId="AAD-LinkSSOToExistingUser" />
</ClaimsExchanges>
</OrchestrationStep>
<!-- Step 8: Collect display name etc. for brand-new SSO users -->
<OrchestrationStep Order="8" Type="ClaimsExchange">
<Preconditions>
<Precondition Type="ClaimsExist" ExecuteActionsIf="true">
<Value>objectId</Value>
<Action>SkipThisOrchestrationStep</Action>
</Precondition>
</Preconditions>
<ClaimsExchanges>
<ClaimsExchange Id="SelfAsserted-Social" TechnicalProfileReferenceId="SelfAsserted-Social" />
</ClaimsExchanges>
</OrchestrationStep>
<!-- Step 9: Read local-account user attributes for the token -->
<OrchestrationStep Order="9" Type="ClaimsExchange">
<Preconditions>
<Precondition Type="ClaimEquals" ExecuteActionsIf="true">
<Value>authenticationSource</Value>
<Value>socialIdpAuthentication</Value>
<Action>SkipThisOrchestrationStep</Action>
</Precondition>
</Preconditions>
<ClaimsExchanges>
<ClaimsExchange Id="AADUserReadWithObjectId" TechnicalProfileReferenceId="AAD-UserReadUsingObjectId" />
</ClaimsExchanges>
</OrchestrationStep>
<!-- Step 10: Write new SSO user to directory if not already written -->
<OrchestrationStep Order="10" Type="ClaimsExchange">
<Preconditions>
<Precondition Type="ClaimEquals" ExecuteActionsIf="true">
<Value>isForgotPassword</Value>
<Value>true</Value>
<Action>SkipThisOrchestrationStep</Action>
</Precondition>
<Precondition Type="ClaimsExist" ExecuteActionsIf="true">
<Value>objectId</Value>
<Action>SkipThisOrchestrationStep</Action>
</Precondition>
</Preconditions>
<ClaimsExchanges>
<ClaimsExchange Id="AADUserWrite" TechnicalProfileReferenceId="AAD-UserWriteUsingAlternativeSecurityId" />
</ClaimsExchanges>
</OrchestrationStep>
<OrchestrationStep Order="11" Type="SendClaims" CpimIssuerTechnicalProfileReferenceId="JwtIssuer" />
</OrchestrationSteps>
<ClientDefinition ReferenceId="DefaultWeb" />
</UserJourney>
</UserJourneys>
</TrustFrameworkPolicy>
Key takeaways
- Migration happens silently on first SSO login
- Only migrated users are restricted (not all users)
- Forgot password remains fully functional for non-migrated users
- A single flag (extension_ssoMigrated) controls everything
- No duplication, no confusion, no manual effort
This approach removes the usual friction of identity migration.
Users don’t need to “move” to SSO—the system does it for them, automatically and securely, while preserving a smooth experience for those who haven’t migrated yet.