Building a Self-Sign-Up Flow with Entra External ID and CIAM
How we built a controlled self-sign-up experience with Entra External ID using onAttributeCollectionStart, onAttributeCollectionSubmit, and onTokenIssuanceStart.
Introduction
Building a customer self-sign-up flow sounds simple:
Let the user enter their email, fill in a few details, and create an account.
In a real CIAM application, however, there is another important question:
Who is actually allowed to sign up?
In our case, we already had an Identity Management system (IDM) that knows which customers are allowed to use our application. We wanted to keep that control while allowing the user to create their own identity in Microsoft Entra External ID.
We also wanted to avoid creating a B2B guest invitation for every customer. Because Microsoft.Graph.Models.Invitation is built for B2B collaboration, not CIAM.
The /invitations Graph API creates B2B guest accounts. These accounts are designed for external users who need access to resources in your tenant, typically through an invitation-based collaboration model. That’s quite different from a customer-facing CIAM scenario, where users should be able to sign themselves up and manage their own identity.
Some of those practical issues with the invitation-based approach:
- Some users didn’t remember their company SSO password.
- Some weren’t aware that they were expected to sign in through their company SSO.
- Some had a different email address registered for SSO than the one we had in our system.
- Group email addresses weren’t supported.
This led us to build an admin-approved self-sign-up flow using Entra External ID and its custom authentication extensions.
The interesting part of the implementation is not just the sign-up form. It is how we use three different Entra extension points, each at a different stage of the identity journey:
Each callback has its own role, and keeping those responsibilities separate turned out to be one of the most important parts of the design.
Keeping What Works: Our Existing SSO / B2B Flow
Before getting into what we changed, it’s worth clarifying what we kept. Our existing SSO/B2B flow is still in place for users who sign in with their company credentials.
The flow is straightforward:
- An admin creates the user with their name, surname, phone number, email address, role, and organisational unit.
- The user is created in Entra through the Graph API
/invitationsendpoint. - The user is also created in our Identity Management system (IDM).
- The user receives an invitation email with a link to join.
- If they have a company SSO, they sign in using their existing credentials.
- If they don’t, they can use the one-time password (OTP) provided in the invitation email.
This works well for B2B scenarios, where users already have a corporate identity and are being invited to access our application.
The new flow solves a different problem: customers who don’t already have an account and need to register themselves.
The User Journey
Before diving into the individual callbacks, it helps to look at the complete journey from the user’s perspective.
We start by creating a minimal self-sign-up record in IDM:
Email
Roles
Organisation UnitIsSelfSignUpUser = true
IsWaitingForSelfSignUp = trueThe user then receives a self-sign-up email with a link to the portal.
From there, the journey looks like this:
- The user clicks “No account? Create one”.
- They enter their email address.
- Entra sends a one-time passcode (OTP) to that email address.
- The user enters the OTP.
- Entra starts the attribute collection flow.
onAttributeCollectionStartis triggered.- The user is shown the sign-up form.
- The user fills in and submits the form.
onAttributeCollectionSubmitis triggered.- Our IDM backend checks whether the user is allowed to register.
- Entra continues creating the identity.
onTokenIssuanceStartis triggered.- IDM links the newly created Entra identity to the existing IDM record.
- Entra issues the token.
The key point is that our backend doesn’t handle the entire sign-up process in a single step. Instead, it participates at several points in the Entra journey, with each callback handling a specific part of the process.
This separation gives us much more control over the sign-up experience while still letting Entra handle the identity and authentication flow.
Understanding the Three Entra Extension Points
One of the most important things we learned while building this flow is that these callbacks aren’t interchangeable webhooks.
Each one runs at a different stage of the Entra journey, which means they have different responsibilities.
In our implementation, we use them like this:
Let’s look at each one.
1. onAttributeCollectionStart
It runs when Entra starts the attribute collection stage, before the user submits the form. This makes it useful for controlling the initial state of the attribute collection.
This stage gives us an opportunity to work with the user’s attributes before they see and complete the sign-up form.
Depending on the information available to the extension, we can use it to:
- Prefill an attribute with a value we already know.
- Set a default value for an attribute.
- Modify an initial value before the form is displayed.
- Prepare values that will be needed in the next stage of the journey.
- Control how attribute collection starts based on the information available in the request or context.
This becomes especially useful when our application already knows something about the user.
For example, in our case, the user’s email address is already known because an administrator has pre-registered them in IDM. Instead of asking the user to provide information we already have, we can use that existing data as part of the sign-up journey.
Start is not Submit
This distinction is important. onAttributeCollectionStart happens before the user interacts with the form. onAttributeCollectionSubmit happens after the user clicks Next/Submit.
2. onAttributeCollectionSubmit
After the user completes the form and clicks Next, Entra calls: onAttributeCollectionSubmit
This is where our IDM backend performs the main registration validation.
Our configured target URL is: /UserSelfSignUp/AttributeCollectionSubmit
Get Ece’s stories in your inbox
Join Medium for free to get updates from this writer.
This maps to the AttributeCollectionSubmit action in our UserSelfSignUpController. The target URL is particularly important because the path needs to match our controller/action configuration across environments.
The AzureId Problem — and How We Solved It
Here’s a subtle but important gotcha: AttributeCollectionSubmit does not return the user's AzureAdId(objectId) at callback time. Here’s a related issue in Stack Overflow: https://stackoverflow.com/questions/78161261/entra-external-id-attributecollectionsubmit-does-not-include-objectid
This means we can’t link the IDM record to the Entra account at the moment of form submission. Instead, we defer this step to the TokenIssuanceStart event — which does include the AzureAdId. At that point:
- We check whether the user is registered in IDM and has
IsWaitingForSelfSignUp = true - If yes, we link the IDM record to the AzureAdId
- We set
IsWaitingForSelfSignUp = false
Privacy note: For security reasons, we deliberately do not store first name, last name, or phone number in IDM backend. These are always fetched on-demand from Entra.
Here’s a diagram of the full Entra authentication flow showing where the custom extension hooks fire:
onAttributeCollectionStart, onAttributeCollectionSubmit, and TokenIssuanceStart3. onTokenIssuanceStart
After the attribute collection has successfully completed and Entra is ready to issue the token, our third extension is called: onTokenIssuanceStart
This is where we can finally access the information we couldn’t get at onAttributeCollectionSubmit.
Specifically:
The AzureAdId is now available.
This makes onTokenIssuanceStart the right place to establish the relationship between the Entra identity and our IDM user.
Setting Up the Flow in Entra External ID
Step 1 — Create Custom Authentication Extensions
The first thing to configure is the custom authentication extensions in Entra External ID, found under Enterprise Applications → Custom Authentication Extensions.
You’ll need two extensions for the self-sign-up flow:
onAttributeCollectionStart(AttributeCollectionStart)onAttributeCollectionSubmit(AttributeCollectionSubmit)
The Target URL for each extension must point to the corresponding endpoint in the backend application. The path must be consistent across all environments (dev, acceptance, production) because it maps directly to the controller and action name in your codebase.
TokenIssuanceStart and AttributeCollectionSubmit extensions configured.AttributeCollectionSubmit custom extension. The Target URL path (/UserSelfSignUp/AttributeCollectionSubmit) must match the controller and action name in your IDM/backend code.Step 2 — Add Custom User Attributes
Entra includes several built-in attributes (first name, last name, postal code, etc.), but phone number is not natively supported — it must be added as a custom attribute under External Identities → Custom user attributes.
Once the custom attribute is added, the AttributeCollectionSubmit extension needs to be wired up to your existing user flow.
⚠️ One quirk to be aware of: With custom directory extension attributes, simply returning ContinueWithDefaultBehavior will silently drop the value. If you want the value to be saved, you need to return it through ModifyAttributeValues as well, without changing it. This is how we persist the phone number, which is then backfilled into Entra’s built-in mobilePhone field when the user signs in for the first time.
Step 3 — Wire Up Both Extensions to the User Flow
Inside your user flow, navigate to Custom authentication extensions to assign which extension fires at each step:
- Before collecting information from the user → assign your
onAttributeCollectionStartextension (I am not using this extension, that’s why none was selected.) - When a user submits their information → assign your
onAttributeCollectionSubmitextension
ciam-approval-on-submit is assigned to the submit step. You can optionally assign a start extension to the collection start step.Step 4 — Configure Email One-Time Passcode as the Identity Provider
Under the user flow’s Identity Providers settings, select Email one-time passcode instead of email with password. This replaces traditional password-based authentication for self-sign-up users, which is exactly what we want — new users haven’t set a password yet.
Step 5 — Configure the Sign-Up Form Fields
Under Page Layouts → User Flow Attributes, configure which attributes appear in the sign-up form and in what order.
givenName and surname are required; PhoneNumber and displayName are optional.Note that displayName is excluded from what the user fills in — it's automatically derived by concatenating givenName and surname. You can confirm this by editing the attribute and seeing that Collect from user is unchecked:
displayName, Collect from user is deliberately unchecked — Entra derives it automatically from first and last name.// Step 3 - Default the display name. Entra otherwise leaves it empty when the sign-up page
// doesn't collect it, so it is derived from the submitted first and last name.
var displayName = $"{request.FirstName?.Trim()} {request.LastName?.Trim()}".Trim();
var attributes = new Dictionary<string, object> { ["displayName"] = displayName };
...
return AttributeCollectionSubmitResponse.ModifyAttributeValues(attributes);How Phone Number Storage Actually Works
Phone number is stored as a directory extension — a key-value pair namespaced by the app that declared it. The naming format is rigid:
extension_{appIdWithoutHyphens}_{AttributeName}The appId here is not your application's client ID. It belongs to a special app that Entra auto-provisions in every CIAM/B2C tenant (historically called b2c-extensions-app), which exists solely to own custom attribute definitions.
b2c-extensions-app under App Registrations by searching "b2c". Its Application (client) ID is the one you need — not your own app's client ID.⚠️ Important: The b2c-extensions-app has a different ID in every tenant — dev, acceptance, and production each have their own instance. This is why the extensions app ID must be stored as an environment-level configuration variable rather than hardcoded.
When configuring the Phone Number custom attribute, make sure to set it up as shown below — with Collect from user, Editable, and Write to directory all enabled, and a validation regex of ^[0-9]*$ to restrict input to digits only:
^[0-9]*$ rejects any non-numeric characters. The ID field (redacted) contains the namespaced extension name.Here’s how the phone number extension is fetched in code:
var extensionName = GraphExtensionAttributes.BuildExtensionName(
configuration.GetValue<string>("B2C_EXTENSIONS_APP_ID"),
PhoneNumberAttributeName); // → extension_<appid>_PhoneNumber
if (extensionName is null) return null; // unconfigured → skip silently
var graphUser = await graphApiClient.GetGraphClient()
.Users[azureAdId]
.GetAsync(config => config.QueryParameters.Select = [extensionName], cancellationToken);
return GraphExtensionAttributes.GetPhoneNumber(graphUser?.AdditionalData);After the form is submitted, the onTokenIssuanceStart endpoint injects the custom phone number into the user's mobilePhone field in Entra — but only if it isn't already set (idempotency guard):
if (graphUser is null || !string.IsNullOrWhiteSpace(graphUser.MobilePhone))
return; // idempotency guard — never overwrite
try
{
await graphApiClient.GetGraphClient()
.Users[graphUser.Id]
.PatchAsync(new GraphUser { MobilePhone = phoneNumber }, cancellationToken);
}
catch (Exception exception)
{
logService.DbUpdateRecordFailed(
$"Failed to copy collected phone number for user {graphUser.Id}: {exception.Message}");
}Step 6 — Associate the User Flow with Your Application
The final step is to link the user flow to your application under the Applications tab in the user flow settings. This enables the sign-up experience for users who navigate to that app.
The Notification Email
When an Admin pre-registers a user, an Azure Function sends a self-sign-up notification email via a transactional email provider. This email contains a link to the portal’s registration flow.
Unlike the existing B2B invitation email, this notification omits firstName, lastName, and redeemUrl — because at this point the user hasn't created their Entra account yet, and no Graph invitation is involved.
The endpoint accepts a simple request body:
{
"email": "user@example.com",
"registrationUrl": "https://portal.example.com/register?email=user%40example.com"
}A dedicated email template was created for this notification across all environments.
Key Takeaways
/invitationsis for B2B, not CIAM. If you're building a customer-facing product on Entra External ID, don't useMicrosoft.Graph.Models.Invitationto onboard users.- Use both
onAttributeCollectionStartandonAttributeCollectionSubmit. The start hook lets you pre-populate or gate the form before the user sees it; the submit hook validates on the way out. AttributeCollectionSubmitdoesn't give you the AzureAdId. Defer user linking toTokenIssuanceStart.- Custom directory extension attributes need special handling. Use
ModifyAttributeValuesto echo values back — otherwise Entra silently drops them. - The b2c-extensions-app ID differs per tenant. Always externalise it as a configuration variable.
- Don’t store PII you don’t need to. Fetch names and phone numbers from Entra on demand rather than duplicating them in your own database.










