Blog Post

Azure Architecture Blog
15 MIN READ

Automatic SSO Takeover in Azure AD B2C Custom Policies

anammalu's avatar
anammalu
Icon for Microsoft rankMicrosoft
Apr 23, 2026

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:

  1. Detect when a user signs in via SSO
  2. Check if a local account exists with the same email
  3. Automatically link the identities
  4. Set a flag: extension_ssoMigrated = true
  5. Enforce SSO-only access going forward
ScenarioOutcome
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

  1. User clicks “Sign in with Microsoft”
  2. System checks existing SSO account
  3. If none → lookup by email
  4. If match → link + mark migrated
  5. 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.

Updated Apr 23, 2026
Version 1.0
No CommentsBe the first to comment