AAD B2C Custom Policy | How to fix TOTP/MFA flow for new user to have MFA code step only once? User is prompted to enter MFA code twice in the Set Password journey for new user.

Kiran Zende 80 Reputation points
2024-05-17T11:38:17.06+00:00

Hello Team,

We've deployed a user authentication flow using Azure AD B2C Custom policy.

Our process entails sending an email notification to new Local B2C users, prompting them to set a password and enable MFA (Multi-Factor Authentication).

Here's the expected behavior for this flow:

  1. User sets password.
  2. User is prompted with a QR code, which they scan using their authenticator app.
  3. User enters the code generated by the authenticator app.
  4. Successful authentication and redirection to redirect_uri.

However, we've encountered an issue that needs addressing:

  1. User sets password.
  2. User is prompted with a QR code, which they scan using their authenticator app.
  3. User is prompted to enter the code. After entering the code, they are prompted again to enter another code.
  4. User waits for the app to generate a new code and enters the new code (as the same code doesn't work again).
  5. Successful authentication and redirection to redirect_uri.

Additionally, we've implemented a feature allowing users to reset MFA. However, when users attempt to set up new MFA, they encounter the same behavior of entering the MFA code twice.

Ideally, we aim to design our flows in a manner that if MFA is not set up or not found, users are prompted to scan the QR code and enter the code. Otherwise, they should only be asked to enter the code.

Could you please assist us in rectifying this flow?


  <UserJourneys>
    <!-- START UserJourney | Set new password - MFA - JWT -->
    <UserJourney Id="SetNewUserPassword">
      <OrchestrationSteps>
        <!-- Read the input claims from the id_token_hint-->
        <OrchestrationStep Order="1" Type="GetClaims" CpimIssuerTechnicalProfileReferenceId="IdTokenHint_ExtractClaims" />
         <!-- Check if user tries to run the policy without valid id_token -->
         <OrchestrationStep Order="2" Type="ClaimsExchange">
          <Preconditions>
             <Precondition Type="ClaimsExist" ExecuteActionsIf="true">
               <Value>email</Value>
               <Action>SkipThisOrchestrationStep</Action>
             </Precondition>
           </Preconditions>
           <ClaimsExchanges>
             <ClaimsExchange Id="SelfAsserted-Unsolicited" TechnicalProfileReferenceId="SelfAsserted-Unsolicited"/>
           </ClaimsExchanges>
         </OrchestrationStep>
        <OrchestrationStep Order="3" Type="ClaimsExchange">
          <ClaimsExchanges>
            <ClaimsExchange Id="PasswordResetUsingEmailAddressExchange" TechnicalProfileReferenceId="LocalAccountDiscoveryUsingPresetEmailAddress" />
          </ClaimsExchanges>
        </OrchestrationStep>
        <!-- Setting new password -->
        <OrchestrationStep Order="4" Type="ClaimsExchange">
          <ClaimsExchanges>
            <ClaimsExchange Id="NewCredentials" TechnicalProfileReferenceId="LocalAccountWritePasswordUsingObjectId" />
          </ClaimsExchanges>
        </OrchestrationStep>
        <!-- Call the TOTP validation sub journey-->
        <OrchestrationStep Order="5" Type="InvokeSubJourney">
          <Preconditions>
            <Precondition Type="ClaimEquals" ExecuteActionsIf="true">
              <Value>isKnownCustomer</Value>
              <Value>True</Value>
              <Action>SkipThisOrchestrationStep</Action>
            </Precondition>
          </Preconditions>
          <JourneyList>
            <Candidate SubJourneyReferenceId="TotpFactor-Input"/>
          </JourneyList>
        </OrchestrationStep>
        <OrchestrationStep Order="6" Type="InvokeSubJourney">
          <Preconditions>
            <Precondition Type="ClaimEquals" ExecuteActionsIf="true">
              <Value>isKnownCustomer</Value>
              <Value>True</Value>
              <Action>SkipThisOrchestrationStep</Action>
            </Precondition>
          </Preconditions>
          <JourneyList>
            <Candidate SubJourneyReferenceId="TotpFactor-Verify"/>
          </JourneyList>
        </OrchestrationStep>
        <OrchestrationStep Order="7" Type="ClaimsExchange">
          <ClaimsExchanges>
            <ClaimsExchange Id="RESTGetPortalUserClaims" TechnicalProfileReferenceId="REST-GetPortalUserClaims"/>
          </ClaimsExchanges>
        </OrchestrationStep>
        <OrchestrationStep Order="8" Type="ClaimsExchange">
          <ClaimsExchanges>
            <ClaimsExchange Id="TrackRESTGetPortalUserClaims" TechnicalProfileReferenceId="AppInsights-REST-GetPortalUserClaims" />
          </ClaimsExchanges>
        </OrchestrationStep>
        <OrchestrationStep Order="9" Type="SendClaims" CpimIssuerTechnicalProfileReferenceId="JwtIssuer" />
        <OrchestrationStep Order="10" Type="ClaimsExchange">
          <ClaimsExchanges>
            <ClaimsExchange Id="TrackSetNewUserPassword" TechnicalProfileReferenceId="AppInsights-SetNewUserPassword" />
          </ClaimsExchanges>
        </OrchestrationStep>
      </OrchestrationSteps>
      <ClientDefinition ReferenceId="DefaultWeb" />
    </UserJourney>


Here are the sub journeys -

  <SubJourneys>
    <!-- START SubJourney | TOTP -->
    <!-- Set the required claims numberOfAvailableDevices and totpIdentifier-->
    <SubJourney Id="SetTotpInitialValue" Type="Call">
      <OrchestrationSteps>
        <!-- If number of available device claim not exists, set the value to 0-->
        <OrchestrationStep Order="1" Type="ClaimsExchange">
          <Preconditions>
            <Precondition Type="ClaimsExist" ExecuteActionsIf="true">
              <Value>numberOfAvailableDevices</Value>
              <Action>SkipThisOrchestrationStep</Action>
            </Precondition>
          </Preconditions>
          <ClaimsExchanges>
            <ClaimsExchange Id="SetTotpDefaultValue" TechnicalProfileReferenceId="SetTotpDefaultValue"/>
          </ClaimsExchanges>
        </OrchestrationStep>
        <!-- The following orchestration steps try to get the user identifier for different
             type of authentication, such as local and social account.-->
        <!-- Try to get the identifier from UserId-->
        <OrchestrationStep Order="2" Type="ClaimsExchange">
          <Preconditions>
            <Precondition Type="ClaimsExist" ExecuteActionsIf="true">
              <Value>totpIdentifier</Value>
              <Action>SkipThisOrchestrationStep</Action>
            </Precondition>
            <Precondition Type="ClaimsExist" ExecuteActionsIf="false">
              <Value>UserId</Value>
              <Action>SkipThisOrchestrationStep</Action>
            </Precondition>
          </Preconditions>
          <ClaimsExchanges>
            <ClaimsExchange Id="SetTotpIdentifierAsUserId" TechnicalProfileReferenceId="CreateTotpIdentifier-UserId"/>
          </ClaimsExchanges>
        </OrchestrationStep>
        <!-- Get the identifier from email-->
        <OrchestrationStep Order="3" Type="ClaimsExchange">
          <Preconditions>
            <Precondition Type="ClaimsExist" ExecuteActionsIf="true">
              <Value>totpIdentifier</Value>
              <Action>SkipThisOrchestrationStep</Action>
            </Precondition>
            <Precondition Type="ClaimsExist" ExecuteActionsIf="false">
              <Value>email</Value>
              <Action>SkipThisOrchestrationStep</Action>
            </Precondition>
          </Preconditions>
          <ClaimsExchanges>
            <ClaimsExchange Id="SetTotpIdentifierAsEmail" TechnicalProfileReferenceId="CreateTotpIdentifier-Email"/>
          </ClaimsExchanges>
        </OrchestrationStep>
        <!-- Get the identifier from the user's emails -->
        <OrchestrationStep Order="4" Type="InvokeSubJourney">
          <Preconditions>
            <Precondition Type="ClaimsExist" ExecuteActionsIf="true">
              <Value>totpIdentifier</Value>
              <Action>SkipThisOrchestrationStep</Action>
            </Precondition>
            <Precondition Type="ClaimsExist" ExecuteActionsIf="false">
              <Value>emails</Value>
              <Action>SkipThisOrchestrationStep</Action>
            </Precondition>
          </Preconditions>
          <JourneyList>
            <Candidate SubJourneyReferenceId="ExtractEmailFromEmailsForTotpIdentifier"/>
          </JourneyList>
        </OrchestrationStep>
        <!-- Get the identifier from local account sign-in name-->
        <OrchestrationStep Order="5" Type="ClaimsExchange">
          <Preconditions>
            <Precondition Type="ClaimsExist" ExecuteActionsIf="true">
              <Value>totpIdentifier</Value>
              <Action>SkipThisOrchestrationStep</Action>
            </Precondition>
            <Precondition Type="ClaimsExist" ExecuteActionsIf="false">
              <Value>signInName</Value>
              <Action>SkipThisOrchestrationStep</Action>
            </Precondition>
          </Preconditions>
          <ClaimsExchanges>
            <ClaimsExchange Id="SetTotpIdentifierAsSignInName" TechnicalProfileReferenceId="CreateTotpIdentifier-SignInName"/>
          </ClaimsExchanges>
        </OrchestrationStep>
      </OrchestrationSteps>
    </SubJourney>
    <!-- Get the identifier from the user's emails -->
    <SubJourney Id="ExtractEmailFromEmailsForTotpIdentifier" Type="Call">
      <OrchestrationSteps>
        <OrchestrationStep Order="1" Type="ClaimsExchange">
          <Preconditions>
            <Precondition Type="ClaimsExist" ExecuteActionsIf="false">
              <Value>emails</Value>
              <Action>SkipThisOrchestrationStep</Action>
            </Precondition>
          </Preconditions>
          <ClaimsExchanges>
            <ClaimsExchange Id="GetEmailAddress" TechnicalProfileReferenceId="GetEmailAddress"/>
          </ClaimsExchanges>
        </OrchestrationStep>
        <OrchestrationStep Order="2" Type="ClaimsExchange">
          <Preconditions>
            <Precondition Type="ClaimsExist" ExecuteActionsIf="false">
              <Value>ReadOnlyEmail</Value>
              <Action>SkipThisOrchestrationStep</Action>
            </Precondition>
          </Preconditions>
          <ClaimsExchanges>
            <ClaimsExchange Id="GetEmailFromReadOnlyEmail" TechnicalProfileReferenceId="GetEmailFromReadOnlyEmail"/>
          </ClaimsExchanges>
        </OrchestrationStep>
        <OrchestrationStep Order="3" Type="ClaimsExchange">
          <Preconditions>
            <Precondition Type="ClaimsExist" ExecuteActionsIf="false">
              <Value>email</Value>
              <Action>SkipThisOrchestrationStep</Action>
            </Precondition>
          </Preconditions>
          <ClaimsExchanges>
            <ClaimsExchange Id="SetTotpIdentifierAsEmail" TechnicalProfileReferenceId="CreateTotpIdentifier-Email"/>
          </ClaimsExchanges>
        </OrchestrationStep>
        <OrchestrationStep Order="4" Type="ClaimsExchange">
          <Preconditions>
            <Precondition Type="ClaimsExist" ExecuteActionsIf="false">
              <Value>signInName</Value>
              <Action>SkipThisOrchestrationStep</Action>
            </Precondition>
          </Preconditions>
          <ClaimsExchanges>
            <ClaimsExchange Id="SetTotpIdentifierAsSignInName" TechnicalProfileReferenceId="CreateTotpIdentifier-SignInName"/>
          </ClaimsExchanges>
        </OrchestrationStep>
      </OrchestrationSteps>
    </SubJourney>
    <!-- TOTP verification sub journey-->
    <SubJourney Id="TotpFactor-Verify" Type="Call">
      <OrchestrationSteps>
        <!-- Set the required claims numberOfAvailableDevices and totpIdentifier-->
        <OrchestrationStep Order="1" Type="InvokeSubJourney">
          <JourneyList>
            <Candidate SubJourneyReferenceId="SetTotpInitialValue"/>
          </JourneyList>
        </OrchestrationStep>
        <!-- If current user is not a new one (this is a sign-in flow, and not sign-up),
             check the number of available devices. -->
        <OrchestrationStep Order="2" Type="ClaimsExchange">
          <Preconditions>
            <Precondition Type="ClaimEquals" ExecuteActionsIf="true">
              <Value>newUser</Value>
              <Value>True</Value>
              <Action>SkipThisOrchestrationStep</Action>
            </Precondition>
          </Preconditions>
          <ClaimsExchanges>
            <ClaimsExchange Id="CheckAvailableDevices" TechnicalProfileReferenceId="AzureMfa-GetAvailableDevices"/>
          </ClaimsExchanges>
        </OrchestrationStep>
        <!-- If the number of available devices isn't zero (user has enrolled before),
             render the TOTP verification page -->
        <OrchestrationStep Order="3" Type="ClaimsExchange">
          <Preconditions>
            <Precondition Type="ClaimEquals" ExecuteActionsIf="true">
              <Value>numberOfAvailableDevices</Value>
              <Value>0</Value>
              <Action>SkipThisOrchestrationStep</Action>
            </Precondition>
          </Preconditions>
          <ClaimsExchanges>
            <ClaimsExchange Id="AuthenticatorForSignIn" TechnicalProfileReferenceId="OTPVerification"/>
          </ClaimsExchanges>
        </OrchestrationStep>
        <OrchestrationStep Order="4" Type="ClaimsExchange">
          <ClaimsExchanges>
            <ClaimsExchange Id="TrackTotpFactorVerify" TechnicalProfileReferenceId="AppInsights-TotpFactor-Verify" />
          </ClaimsExchanges>
        </OrchestrationStep>
      </OrchestrationSteps>
    </SubJourney>
    <!-- TOTP enrollment sub journey-->
    <SubJourney Id="TotpFactor-Input" Type="Call">
      <OrchestrationSteps>
        <!-- Set the required claims numberOfAvailableDevices and totpIdentifier-->
        <OrchestrationStep Order="1" Type="InvokeSubJourney">
          <JourneyList>
            <Candidate SubJourneyReferenceId="SetTotpInitialValue"/>
          </JourneyList>
        </OrchestrationStep>
        <!-- If current user is not a new one (this is a sign-in flow, and not sign-up),
             check the number of available devices. -->
        <OrchestrationStep Order="2" Type="ClaimsExchange">
          <Preconditions>
            <Precondition Type="ClaimEquals" ExecuteActionsIf="true">
              <Value>newUser</Value>
              <Value>True</Value>
              <Action>SkipThisOrchestrationStep</Action>
            </Precondition>
          </Preconditions>
          <ClaimsExchanges>
            <ClaimsExchange Id="CheckAvailableDevices" TechnicalProfileReferenceId="AzureMfa-GetAvailableDevices"/>
          </ClaimsExchanges>
        </OrchestrationStep>
        <!-- If the number of available devices is zero (user hasn't enrolled before),
            render the TOTP enrollment page to scan the QR code that starts the enrollment process -->
        <OrchestrationStep Order="3" Type="ClaimsExchange">
          <Preconditions>
            <Precondition Type="ClaimEquals" ExecuteActionsIf="false">
              <Value>numberOfAvailableDevices</Value>
              <Value>0</Value>
              <Action>SkipThisOrchestrationStep</Action>
            </Precondition>
          </Preconditions>
          <ClaimsExchanges>
            <ClaimsExchange Id="AuthenticatorForSignUp" TechnicalProfileReferenceId="EnableOTPAuthentication"/>
          </ClaimsExchanges>
        </OrchestrationStep>
        <!-- If the number of available devices is zero (user hasn't enrolled before),
             render the TOTP verification page.  -->
        <OrchestrationStep Order="4" Type="ClaimsExchange">
          <Preconditions>
            <Precondition Type="ClaimEquals" ExecuteActionsIf="false">
              <Value>numberOfAvailableDevices</Value>
              <Value>0</Value>
              <Action>SkipThisOrchestrationStep</Action>
            </Precondition>
          </Preconditions>
          <ClaimsExchanges>
            <ClaimsExchange Id="AuthenticatorForSignIn" TechnicalProfileReferenceId="OTPVerification"/>
          </ClaimsExchanges>
        </OrchestrationStep>
      <OrchestrationStep Order="5" Type="ClaimsExchange">
        <ClaimsExchanges>
          <ClaimsExchange Id="TrackTotpFactorInput" TechnicalProfileReferenceId="AppInsights-TotpFactor-Input" />
        </ClaimsExchanges>
      </OrchestrationStep>
    </OrchestrationSteps>
    </SubJourney>
    <!-- END SubJourney | TOTP -->
  </SubJourneys>
Microsoft Entra ID
Microsoft Entra ID
A Microsoft Entra identity service that provides identity management and access control capabilities. Replaces Azure Active Directory.
20,093 questions
0 comments No comments
{count} votes

1 answer

Sort by: Most helpful
  1. Marilee Turscak-MSFT 35,616 Reputation points Microsoft Employee
    2024-05-21T22:46:30.06+00:00

    Hi @Kiran Zende ,

    It's possible that the user may have bookmarked a URL to your B2C tenant that includes the state and nonce parameters in the redirect URL. The user could navigate to the bookmarked URL and enter credentials followed by the one time password. Then Azure AD B2C would issue an authorization code to the application and then that application detects based on the state and nonce values that a new sign-in should be started because the bookmarked values expired and are no longer valid.

    In this scenario the behavior you described is expected. The user triggers the authentication once, and the app rejects it and requires signing in again.

    If the information helped you, please Accept the answer. This will help us and improve searchability for others in the community who may be researching similar questions.