MSFT now allows you to use your own service as API. You can still use B2C to generate and verify the code, but you can hook your own service to send the email with branding.
Unfortunately, there's not much documentation around this. Some articles are around 7 years ago which are not recommended as it lacks security. I have written an article on this with detailed changes to the base policies.
Make below changes to your extension policy
Add Claims
<ClaimType Id="verificationCode">
<DisplayName>Verification Code</DisplayName>
<DataType>string</DataType>
<UserInputType>TextBox</UserInputType>
</ClaimType>
<ClaimType Id="otpGenerated">
<DisplayName>Generated OTP</DisplayName>
<DataType>string</DataType>
</ClaimType>
Display controls need 2.1.9 or higher on the self‑asserted content definition used by your reset page
<ContentDefinition Id="api.localaccountpasswordreset">
<LoadUri>https://YOUR-STATIC-WEB-APP/reset-password.html</LoadUri>
<RecoveryUri>~/common/default_page_error.html</RecoveryUri>
<DataUri>urn:com:microsoft:aad:b2c:elements:contract:selfasserted:2.1.9</DataUri>
</ContentDefinition>
Define the Verification display control
Still in TrustFrameworkExtensions.xml, add under <BuildingBlocks>
<DisplayControls>
<DisplayControl Id="emailVerificationControl" UserInterfaceControlType="VerificationControl">
<DisplayClaims>
<DisplayClaim ClaimTypeReferenceId="email" Required="true" />
<DisplayClaim ClaimTypeReferenceId="verificationCode"
ControlClaimType="VerificationCode"
Required="true" />
</DisplayClaims>
<OutputClaims>
<OutputClaim ClaimTypeReferenceId="email" />
</OutputClaims>
<Actions>
<Action Id="SendCode">
<ValidationClaimsExchange>
<ValidationClaimsExchangeTechnicalProfile TechnicalProfileReferenceId="OTP-GenerateCode" />
<ValidationClaimsExchangeTechnicalProfile TechnicalProfileReferenceId="REST-SendVerificationEmail" />
</ValidationClaimsExchange>
</Action>
<Action Id="VerifyCode">
<ValidationClaimsExchange>
<ValidationClaimsExchangeTechnicalProfile TechnicalProfileReferenceId="OTP-VerifyCode" />
</ValidationClaimsExchange>
</Action>
</Actions>
</DisplayControl>
OTP generate & verify
Add a new ClaimsProvider with two technical profiles:
<ClaimsProvider>
<DisplayName>One-Time Password</DisplayName>
<TechnicalProfiles>
<TechnicalProfile Id="OTP-GenerateCode">
<DisplayName>Generate Code</DisplayName>
<Protocol Name="Proprietary"
Handler="Web.TPEngine.Providers.OneTimePasswordProtocolProvider, Web.TPEngine, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" />
<Metadata>
<Item Key="Operation">GenerateCode</Item>
<Item Key="CodeExpirationInSeconds">600</Item>
<Item Key="CodeLength">6</Item>
<Item Key="CharacterSet">0-9</Item>
<Item Key="NumRetryAttempts">5</Item>
<Item Key="ReuseSameCode">false</Item>
</Metadata>
<InputClaims>
<InputClaim ClaimTypeReferenceId="email" PartnerClaimType="identifier" />
</InputClaims>
<OutputClaims>
<OutputClaim ClaimTypeReferenceId="otpGenerated" PartnerClaimType="otpGenerated" />
</OutputClaims>
</TechnicalProfile>
<TechnicalProfile Id="OTP-VerifyCode">
<DisplayName>Verify Code</DisplayName>
<Protocol Name="Proprietary"
Handler="Web.TPEngine.Providers.OneTimePasswordProtocolProvider, Web.TPEngine, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" />
<Metadata>
<Item Key="Operation">VerifyCode</Item>
</Metadata>
<InputClaims>
<InputClaim ClaimTypeReferenceId="email" PartnerClaimType="identifier" />
<InputClaim ClaimTypeReferenceId="verificationCode" PartnerClaimType="otpToVerify" />
</InputClaims>
</TechnicalProfile>
</TechnicalProfiles>
</ClaimsProvider>
REST email sender TP
Add a REST TP that calls your backend to send the code:
<ClaimsProvider>
<DisplayName>Custom Email Provider</DisplayName>
<TechnicalProfiles>
<TechnicalProfile Id="REST-SendVerificationEmail">
<DisplayName>Send Verification Email</DisplayName>
<Protocol Name="Proprietary"
Handler="Web.TPEngine.Providers.RestfulProvider, Web.TPEngine, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" />
<Metadata>
<Item Key="ServiceUrl">https://YOUR-FUNCTION.azurewebsites.net/api/SendVerificationEmail</Item>
<Item Key="SendClaimsIn">Body</Item>
<Item Key="AuthenticationType">None</Item>
<Item Key="AllowInsecureAuthInProduction">false</Item>
</Metadata>
<InputClaims>
<InputClaim ClaimTypeReferenceId="email" PartnerClaimType="to" />
<InputClaim ClaimTypeReferenceId="otpGenerated" PartnerClaimType="code" />
</InputClaims>
<UseTechnicalProfileForSessionManagement ReferenceId="SM-Noop" />
</TechnicalProfile>
</TechnicalProfiles>
</ClaimsProvider>
Override the discovery TP to use the display control
The reset journey usually calls LocalAccountDiscoveryUsingEmailAddress at step 1. Override it (same Id) and attach the display control via DisplayClaims (note: not DisplayControlReferences—that’s invalid XML for B2C TPs)
<ClaimsProvider>
<DisplayName>Local Account</DisplayName>
<TechnicalProfiles>
<TechnicalProfile Id="LocalAccountDiscoveryUsingEmailAddress">
<DisplayName>Reset password using email address</DisplayName>
<Protocol Name="Proprietary"
Handler="Web.TPEngine.Providers.SelfAssertedAttributeProvider, Web.TPEngine, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" />
<Metadata>
<Item Key="ContentDefinitionReferenceId">api.localaccountpasswordreset</Item>
</Metadata>
<IncludeInSso>false</IncludeInSso>
<!-- Hook up the display control -->
<DisplayClaims>
<DisplayClaim DisplayControlReferenceId="emailVerificationControl" />
</DisplayClaims>
<!-- After verification, also ensure the account exists -->
<OutputClaims>
<OutputClaim ClaimTypeReferenceId="email" PartnerClaimType="Verified.Email" Required="true" />
<OutputClaim ClaimTypeReferenceId="objectId" />
<OutputClaim ClaimTypeReferenceId="userPrincipalName" />
<OutputClaim ClaimTypeReferenceId="authenticationSource" />
</OutputClaims>
<ValidationTechnicalProfiles>
<ValidationTechnicalProfile ReferenceId="AAD-UserReadUsingEmailAddress" />
</ValidationTechnicalProfiles>
</TechnicalProfile>
</TechnicalProfiles>
</ClaimsProvider>
Write a simple Azure Function / endpoint to your API which accepts below contract. You can implement whatever email service you want to use. Just return simple 200, as your policy doesn't required any output claim from your rest api
{ "to": "[email protected]", "code": "123456" }
https://medium.com/@jauraamit/b2cazure-ad-b2c-custom-password-reset-email-with-verificationcontrol-otp-1ef0157936c1