Azure AD B2C Custom Policy: Single Relying Party with Separate Sign-In and Sign-Up Links
One Relying Party file. One combined page. But your application needs a standalone Sign-Up link that bypasses the combined UI and goes directly to registration. Here is the complete solution using the OAUTH-KV claim resolver and orchestration step preconditions — no duplicate RP files required.
Why a Standalone Sign-Up Link Does Not Work Out of the Box
The CombinedSignInAndSignUp orchestration step is the standard mechanism for presenting a single UI page that handles both authentication paths. When a user arrives at the page, the sign-in tab is active by default. There is a "Sign up now" link at the bottom of the page that users can click to switch to the registration form — but the default entry point always starts in sign-in mode.
The problem arises when your application needs to provide a standalone "Sign Up" link — a button on your home page or marketing site that takes users directly to the registration form without going through the combined page. By default, invoking the single RP policy always starts in sign-in mode regardless of what your application intends. Passing a custom query parameter like ?action=signup in the authorization URL has no effect out of the box — the Identity Experience Framework (IEF) does not read custom query parameters unless you explicitly configure it to do so.
The solution involves two things working together: the OAUTH-KV claim resolver (which extracts the custom query parameter from the OAuth 2.0 authorization request and maps it to a claim), and orchestration step preconditions (which evaluate the claim value and branch the user journey accordingly). Together, they let a single Relying Party policy produce different user journey paths based on what the application signals in the authorization URL.
The Complete Implementation — Four XML Changes to Your Policy Files
The solution requires four changes spread across your policy files: declare the claim type in the ClaimsSchema, populate it from the OAUTH-KV resolver in the RP file, add the direct sign-up orchestration step to the user journey in Extensions, and add the precondition that skips the combined step when the claim equals "signup". Forgot Password continues to work as a sub-journey from within the combined page — no changes needed there.
Step 1 — Declare the claim type in TrustFrameworkExtensions.xml
Add the clientActionIntent claim type to your ClaimsSchema. This claim will hold the value of the ?action= query string parameter sent by your application. It is a plain string claim with no special constraints — it will be populated by the OAUTH-KV resolver in the next step and then evaluated by preconditions in the user journey.
Place this inside the <BuildingBlocks> <ClaimsSchema> element in your TrustFrameworkExtensions.xml or the policy file where you define your claims schema.
<ClaimType Id="clientActionIntent">
<DisplayName>Client Action Intent</DisplayName>
<DataType>string</DataType>
<UserHelpText>Action intent passed by the relying party application (e.g. signup)</UserHelpText>
</ClaimType>
<!-- This claim will receive the value of ?action= from the OAuth2 authorization request -->
<!-- When action=signup is passed, it drives the precondition in the user journey -->
Step 2 — Populate the claim from the OAUTH-KV resolver in the Relying Party file
In your Relying Party (RP) XML file — typically named B2C_1A_Signup_Signin.xml or similar — add an InputClaim that uses the {OAUTH-KV:action} resolver as its DefaultValue. The OAUTH-KV claim resolver extracts the value of any custom query parameter passed in the OAuth 2.0 authorization request. {OAUTH-KV:action} reads ?action=signup from the URL and maps it to the clientActionIntent claim.
The AlwaysUseDefaultValue="true" attribute is critical — without it, the resolver only populates the claim if no prior value exists. With it, the resolver always overwrites the claim with the current request's value, which is what you need.
<RelyingParty>
<DefaultUserJourney ReferenceId="SignUpOrSignIn" />
<TechnicalProfile Id="PolicyProfile">
<DisplayName>PolicyProfile</DisplayName>
<Protocol Name="OpenIdConnect" />
<InputClaims>
<!-- Read the ?action= query parameter from the OAuth2 authorization request -->
<!-- {OAUTH-KV:action} maps the &action= URL param to clientActionIntent claim -->
<InputClaim
ClaimTypeReferenceId="clientActionIntent"
DefaultValue="{OAUTH-KV:action}"
AlwaysUseDefaultValue="true" />
</InputClaims>
<OutputClaims>
<!-- Your existing output claims (objectId, email, displayName, etc.) -->
<OutputClaim ClaimTypeReferenceId="displayName" />
<OutputClaim ClaimTypeReferenceId="email" />
<OutputClaim ClaimTypeReferenceId="objectId" PartnerClaimType="sub" />
<!-- clientActionIntent is NOT included in output claims -->
<!-- It is internal routing only — do not expose it in the JWT token -->
</OutputClaims>
<SubjectNamingInfo ClaimType="sub" />
</TechnicalProfile>
</RelyingParty>
Step 3 — Modify the user journey with branching orchestration steps
This is the core of the solution. The user journey needs two orchestration steps at the beginning — one for the direct sign-up path (skipped unless clientActionIntent = signup) and one for the combined sign-in/sign-up path (skipped if clientActionIntent = signup). The preconditions on each step control which path runs.
The combined step uses a ClaimEquals precondition: if clientActionIntent equals signup, skip this step (and show the direct sign-up instead). The direct sign-up step uses the inverse: skip it unless clientActionIntent equals signup.
The Forgot Password sub-journey remains on the combined step — it is triggered by the user clicking "Forgot password" on the combined UI, not by the query parameter.
<UserJourney Id="SignUpOrSignIn">
<OrchestrationSteps>
<!-- ─────────────────────────────────────────────────────────────────── -->
<!-- STEP 1 — Direct Sign-Up (only runs when action=signup is passed) -->
<!-- Skip this step if clientActionIntent is absent OR not equal to signup -->
<!-- ─────────────────────────────────────────────────────────────────── -->
<OrchestrationStep Order="1" Type="ClaimsExchange">
<Preconditions>
<!-- Skip if clientActionIntent claim does not exist at all -->
<Precondition Type="ClaimsExist" ExecuteActionsIf="false">
<Value>clientActionIntent</Value>
<Action>SkipThisOrchestrationStep</Action>
</Precondition>
<!-- Skip if clientActionIntent exists but is NOT 'signup' -->
<Precondition Type="ClaimEquals" ExecuteActionsIf="false">
<Value>clientActionIntent</Value>
<Value>signup</Value>
<Action>SkipThisOrchestrationStep</Action>
</Precondition>
</Preconditions>
<ClaimsExchanges>
<!-- This technical profile shows only the sign-up/registration form -->
<ClaimsExchange
Id="DirectSignUpWithLocalAccount"
TechnicalProfileReferenceId="LocalAccountSignUpWithLogonEmail" />
</ClaimsExchanges>
</OrchestrationStep>
<!-- ─────────────────────────────────────────────────────────────────── -->
<!-- STEP 2 — Combined Sign-In / Sign-Up (normal entry point) -->
<!-- Skip this step if action=signup was passed (direct signup ran in Step 1) -->
<!-- ─────────────────────────────────────────────────────────────────── -->
<OrchestrationStep Order="2" Type="CombinedSignInAndSignUp" ContentDefinitionReferenceId="api.signuporsignin">
<Preconditions>
<!-- Skip the combined page if the user already completed direct sign-up in Step 1 -->
<!-- After LocalAccountSignUpWithLogonEmail, objectId will be in the claims bag -->
<Precondition Type="ClaimsExist" ExecuteActionsIf="true">
<Value>objectId</Value>
<Action>SkipThisOrchestrationStep</Action>
</Precondition>
</Preconditions>
<ClaimsProviderSelections>
<ClaimsProviderSelection ValidationClaimsExchangeId="LocalAccountSigninEmailExchange" />
</ClaimsProviderSelections>
<ClaimsExchanges>
<ClaimsExchange
Id="LocalAccountSigninEmailExchange"
TechnicalProfileReferenceId="SelfAsserted-LocalAccountSignin-Email" />
</ClaimsExchanges>
</OrchestrationStep>
<!-- ─────────────────────────────────────────────────────────────────── -->
<!-- STEP 3 — Read user from directory (after sign-in completes) -->
<!-- Skipped if objectId already populated from direct sign-up -->
<!-- ─────────────────────────────────────────────────────────────────── -->
<OrchestrationStep Order="3" Type="ClaimsExchange">
<Preconditions>
<Precondition Type="ClaimsExist" ExecuteActionsIf="true">
<Value>objectId</Value>
<Action>SkipThisOrchestrationStep</Action>
</Precondition>
</Preconditions>
<ClaimsExchanges>
<ClaimsExchange
Id="AADUserReadWithObjectId"
TechnicalProfileReferenceId="AAD-UserReadUsingObjectId" />
</ClaimsExchanges>
</OrchestrationStep>
<!-- ─────────────────────────────────────────────────────────────────── -->
<!-- STEP 4 — Issue the JWT token to the relying party -->
<!-- ─────────────────────────────────────────────────────────────────── -->
<OrchestrationStep Order="4" Type="SendClaims" CpimIssuerTechnicalProfileReferenceId="JwtIssuer" />
</OrchestrationSteps>
</UserJourney>
Step 4 — Generate the authorization URLs in your application
With the policy in place, your application constructs two distinct authorization URLs — both pointing to the same RP policy, but the sign-up URL includes &action=signup as an extra parameter. MSAL automatically passes extra parameters through to B2C when you include them in the authorization request.
// Sign-In link (standard — goes to combined page in sign-in mode)
// No ?action= parameter → clientActionIntent is empty → CombinedSignInAndSignUp runs
const signInRequest = {
scopes: ["openid", "profile"],
authority: "https://YOUR-TENANT.b2clogin.com/YOUR-TENANT.onmicrosoft.com/B2C_1A_Signup_Signin"
// No extra parameters needed for sign-in
};
// Sign-Up link (standalone — goes directly to registration form)
// ?action=signup → clientActionIntent = 'signup' → Step 1 (direct sign-up) runs
const signUpRequest = {
scopes: ["openid", "profile"],
authority: "https://YOUR-TENANT.b2clogin.com/YOUR-TENANT.onmicrosoft.com/B2C_1A_Signup_Signin",
extraQueryParameters: { action: "signup" } // This becomes ?action=signup in the auth URL
};
// Using MSAL.js — both call loginRedirect (or loginPopup) with the appropriate request
// Sign-In button: msalInstance.loginRedirect(signInRequest);
// Sign-Up button: msalInstance.loginRedirect(signUpRequest);
// Raw authorization URL structure (for non-MSAL integrations):
// Sign-In: https://YOUR-TENANT.b2clogin.com/YOUR-TENANT.onmicrosoft.com/oauth2/v2.0/authorize
// ?p=B2C_1A_Signup_Signin&client_id=...&response_type=code&redirect_uri=...&scope=openid
// Sign-Up: same URL + &action=signup at the end
Forgot Password Sub-Journey — How It Fits In
Forgot Password works as an InvokeSubJourney orchestration step that is triggered when the user clicks "Forgot your password?" on the combined sign-in/sign-up page. In the B2C Identity Experience Framework, this click generates a special AADB2C90118 error code that your orchestration logic intercepts and routes to the sub-journey. This mechanism is independent of the clientActionIntent claim and works correctly without any additional modification to the branching logic above.
Add the Forgot Password sub-journey invocation as an additional orchestration step in your user journey. It sits between the combined step and the SendClaims step, and uses a precondition to skip unless the special forgot-password flag claim is set.
<!-- Add this claim declaration to ClaimsSchema first: -->
<!-- <ClaimType Id="isForgotPassword"><DataType>boolean</DataType></ClaimType> -->
<!-- Inside UserJourney — after Step 2 (CombinedSignInAndSignUp) -->
<OrchestrationStep Order="4" Type="InvokeSubJourney">
<Preconditions>
<!-- Only run the forgot-password sub-journey if isForgotPassword = true -->
<!-- This claim is set when user clicks "Forgot your password?" on the combined page -->
<Precondition Type="ClaimsExist" ExecuteActionsIf="false">
<Value>isForgotPassword</Value>
<Action>SkipThisOrchestrationStep</Action>
</Precondition>
</Preconditions>
<JourneyList>
<Candidate SubJourneyReferenceId="PasswordReset" />
</JourneyList>
</OrchestrationStep>
<!-- The PasswordReset sub-journey handles OTP verification and password reset -->
<!-- It is defined separately as a <SubJourney Id="PasswordReset"> in the extensions file -->
<!-- After the sub-journey completes, execution returns to the parent journey -->
<!-- and continues to the SendClaims step (user is signed in after password reset) -->
Testing the Policy and Debugging with Application Insights
After uploading the updated policy files, test both paths using the "Run now" feature in the Azure portal Identity Experience Framework blade. For the direct sign-up path, add &action=signup to the "Run now" test URL.
?client_id=YOUR-CLIENT-ID
&nonce=defaultNonce
&redirect_uri=https://jwt.ms
&scope=openid
&response_type=id_token
&prompt=login
# Direct sign-up (action=signup triggers Step 1) https://YOUR-TENANT.b2clogin.com/YOUR-TENANT.onmicrosoft.com/B2C_1A_Signup_Signin/oauth2/v2.0/authorize
?client_id=YOUR-CLIENT-ID
&nonce=defaultNonce
&redirect_uri=https://jwt.ms
&scope=openid
&response_type=id_token
&prompt=login
&action=signup <-- This is the key addition
# Debug with Application Insights trace logging enabled # Add &diags:{"applicationInsightsKey":"YOUR-AI-KEY"} to trace orchestration steps # Check Application Insights → Traces → filter by policy name → verify: # clientActionIntent claim value matches what was passed # Correct step is running / being skipped per the precondition logic
Key Takeaways
Related FAVRITE Articles
- How to Configure Conditional Access for Approved Client Apps in Microsoft Entra ID
- Top 50 Azure Cloud Administrator Interview Questions and Answers
- Least Privileged Access Required to View Azure App Service Log Stream