Customer identity is not employee identity wearing a customer hat. Your customers self-register at 2 a.m., bring a Google or Apple account, never call your help desk, and churn the moment sign-up takes more than two screens. Entra External ID is Microsoft’s CIAM platform for exactly this — the successor to Azure AD B2C, but built on the same identity platform stack as your workforce tenant, with custom policies replaced by a far saner extensibility model. This is how I stand up a production-grade external tenant: branded self-service sign-up, federated social and enterprise IdPs, custom attributes, and tokens shaped by REST callouts rather than 2,000-line XML policies.
Scope note: this is the external tenant scenario (customer-facing apps with local consumer accounts), not B2B collaboration in your workforce tenant. They share the “External ID” name and almost nothing else operationally. If you are onboarding partners as guests, that is a different build entirely.
1. External ID vs legacy Azure AD B2C: pick the platform deliberately
Azure AD B2C is in maintenance mode. Microsoft stopped onboarding new B2C tenants for most customers; new CIAM builds go to External ID external tenants. The two are not the same product with a fresh coat of paint:
| Capability | Azure AD B2C | Entra External ID (external tenant) |
|---|---|---|
| Customization model | Custom policies (TrustFrameworkPolicy XML / IEF) | User flows + custom authentication extensions (REST) |
| Identity platform | Legacy B2C stack | Same Microsoft identity platform as workforce |
| Extensibility | API connectors + IEF technical profiles | OnAttributeCollectionStart/Submit + OnTokenIssuanceStart callouts |
| Conditional Access | Limited | Full Entra Conditional Access, ID Protection (P2 features) |
| Sign-in host | <tenant>.b2clogin.com |
<tenant>.ciamlogin.com |
The decision is almost made for you: new builds go to External ID. The reason to care about the distinction is that every B2C tutorial you find is the wrong model. There is no TrustFrameworkExtensions.xml, no RelyingParty, no Identity Experience Framework. If a guide tells you to upload a policy XML, you are reading B2C docs. Stop. Migration from an existing B2C tenant is a section at the end of this article.
2. Provision an external tenant
An external tenant is a distinct directory type. You cannot convert a workforce tenant into one, and consumer accounts must never land in your employee directory. There is no first-class az command to create it; the supported automation path is the ARM resource provider Microsoft.AzureActiveDirectory/ciamDirectories.
# The ciamDirectories create call requires a *delegated user* token,
# not an app-only token. Run interactively as a user with rights to
# create tenants in the subscription.
az rest --method PUT \
--url "https://management.azure.com/subscriptions/$SUB_ID/resourceGroups/rg-ciam/providers/Microsoft.AzureActiveDirectory/ciamDirectories/contosoexternal?api-version=2023-05-17-preview" \
--body '{
"location": "United States",
"sku": { "name": "Standard", "tier": "A0" },
"properties": {
"createTenantProperties": {
"displayName": "Contoso Customers",
"countryCode": "US"
}
}
}'
The location is the data residency region for the directory; choose it for compliance, not latency (auth endpoints are geo-distributed regardless). Bicep works too, but note the same constraint:
resource ciam 'Microsoft.AzureActiveDirectory/ciamDirectories@2023-05-17-preview' = {
name: 'contosoexternal'
location: 'United States'
sku: { name: 'Standard', tier: 'A0' }
properties: {
createTenantProperties: {
displayName: 'Contoso Customers'
countryCode: 'US'
}
}
}
Automation reality: the creation must be done by a user identity once. After the tenant exists, promote a service principal to a directory role inside the new tenant and drive all subsequent configuration (app registrations, user flows, IdPs, extensions) through Microsoft Graph with app-only credentials. Do not try to make a managed identity create the directory; it will fail with an auth error.
Once created, switch context to the new tenant. Every Graph call below targets the external tenant, whose authority is https://<tenant>.ciamlogin.com/<tenant-id>.
3. Register the customer-facing application
Register the app that customers will actually sign in to. For a SPA or mobile app, use the SPA/public-client platform with PKCE — never a client secret in a browser or on a device.
# Run against the external tenant. Authority: https://contoso.ciamlogin.com/<tenant-id>
az rest --method POST \
--url "https://graph.microsoft.com/v1.0/applications" \
--body '{
"displayName": "Contoso Storefront SPA",
"signInAudience": "AzureADMyOrg",
"spa": {
"redirectUris": ["https://shop.contoso.com/auth/callback"]
},
"web": { "redirectUris": [] },
"requiredResourceAccess": [
{
"resourceAppId": "00000003-0000-0000-c000-000000000000",
"resourceAccess": [
{ "id": "e1fe6dd8-ba31-4d61-89e7-88639da4683d", "type": "Scope" }
]
}
]
}'
That e1fe6dd8-... is the delegated User.Read scope on Microsoft Graph — the minimum. For your own protected API, register a second app, expose a scope via api://<app-id>/access_as_user, and pre-authorize the SPA. Keep the resource API and the client as separate registrations; collapsing them is the single most common security mistake in CIAM apps because it forces over-broad consent.
4. Design the self-service sign-up and sign-in user flow
User flows replace B2C custom policies for the common case. One flow defines the sign-up steps, the first-factor methods, and which attributes you collect. An app can have exactly one user flow; a flow can serve many apps.
First, define any custom attributes at the tenant level so they are available to every flow. Custom attributes are stored on an extension app (b2c-extensions-app) and surface in Graph as extension_<appId-without-hyphens>_<name>.
# Define a tenant-level custom attribute, e.g. loyaltyTier
az rest --method POST \
--url "https://graph.microsoft.com/v1.0/identity/userFlowAttributes" \
--body '{
"displayName": "loyaltyTier",
"description": "Customer loyalty tier captured at sign-up",
"dataType": "string"
}'
Then create the sign-up/sign-in flow. In the admin center this is Entra ID > External Identities > User flows > New user flow; pick Email Accounts with either Email with password or Email one-time passcode as the first factor, then select the built-in and custom attributes to collect. Programmatically, the flow is an authenticationEventsFlow (externalUsersSelfServiceSignUp subtype) in the beta Graph endpoint:
POST https://graph.microsoft.com/beta/identity/authenticationEventsFlows
Content-Type: application/json
{
"@odata.type": "#microsoft.graph.externalUsersSelfServiceSignUpEventsFlow",
"displayName": "StorefrontSignUpSignIn",
"onInteractiveAuthFlowStart": {
"@odata.type": "#microsoft.graph.onInteractiveAuthFlowStartExternalUsersSelfServiceSignUp",
"isSignUpAllowed": true
},
"onAuthenticationMethodLoadStart": {
"@odata.type": "#microsoft.graph.onAuthenticationMethodLoadStartExternalUsersSelfServiceSignUp",
"identityProviders": [
{ "id": "EmailPassword-OAUTH" }
]
},
"onAttributeCollection": {
"@odata.type": "#microsoft.graph.onAttributeCollectionExternalUsersSelfServiceSignUp",
"attributes": [
{ "id": "email" },
{ "id": "displayName" },
{ "id": "city" }
]
}
}
A pragmatic note on first factor: prefer email one-time passcode over email+password unless you have a hard requirement for passwords. It removes the entire password-reset, breach-replay, and complexity-policy surface, and customers convert better. You can layer SMS or email OTP as a second factor for MFA on top of either.
The Stay signed in? prompt appears by default after sign-in and is not a user flow setting. To suppress it, use a Conditional Access Persistent browser session control (Always persistent / Never persistent) targeting your customers and app — there is no toggle on the flow itself.
5. Federate social and enterprise identity providers
Local accounts get you to launch; social and enterprise federation gets you adoption. External ID supports Google, Facebook, and Apple as built-in social providers, plus arbitrary OIDC (and SAML for enterprise) federation.
For each social provider, you register an OAuth app on their side, then store the client ID/secret in Entra. Google is representative — note the redirect URI uses the ciamlogin.com host:
https://<tenant-subdomain>.ciamlogin.com/<tenant-id>/federation/oauth2
https://<tenant-id>.ciamlogin.com/<tenant-id>/federation/oidc/accounts.google.com
Then create the identity provider in Graph (or PowerShell). The type is socialIdentityProvider:
Connect-MgGraph -Scopes "IdentityProvider.ReadWrite.All"
Import-Module Microsoft.Graph.Identity.SignIns
$params = @{
"@odata.type" = "microsoft.graph.socialIdentityProvider"
displayName = "Sign in with Google"
identityProviderType = "Google"
clientId = "<google-oauth-client-id>"
clientSecret = "<google-oauth-client-secret>"
}
New-MgIdentityProvider -BodyParameter $params
Apple uses the same shape with identityProviderType = "Apple" but authenticates with a generated client secret JWT signed by your Apple private key, which expires — automate its rotation or it will silently break sign-in every six months. For an enterprise OIDC IdP (a partner’s Okta or another Entra tenant), use openIdConnectIdentityProvider with the well-known config URL, client ID/secret, scopes, and a responseType of code. SAML federation is available for enterprise IdPs that do not speak OIDC; reach for it only when the partner cannot offer OIDC, since SAML metadata management is heavier.
Creating the IdP only registers it. You must then add it to the user flow’s identity providers (admin center: the flow’s Identity providers blade) before it appears on the sign-in page. A federated provider that is configured but not attached to a flow is invisible to users — a frequent “why is my Google button missing” support ticket.
6. Branding, localization, and custom URL domains
Out of the box, customers sign in at https://<tenant>.ciamlogin.com, which screams “this is not Contoso.” Three layers fix that.
Company branding (logos, background, colors, sign-in text) is set per tenant and applies to all flows. It is the organizationalBranding resource in Graph; the default locale plus per-language overrides give you localization — upload a localized branding object per language and the sign-in UI follows the browser Accept-Language.
# Set the default branding (background color, sign-in page text)
az rest --method PATCH \
--url "https://graph.microsoft.com/v1.0/organization/$TENANT_ID/branding" \
--headers "Content-Language=default" \
--body '{
"backgroundColor": "#0B5FFF",
"signInPageText": "Welcome to Contoso. Sign in to manage your account.",
"usernameHintText": "name@example.com"
}'
Custom URL domains (now GA) put your own domain on the auth endpoint, so customers stay on login.contoso.com instead of contoso.ciamlogin.com. This is not cosmetic: keeping the user in your domain mitigates third-party cookie blocking that otherwise breaks silent token renewal in Safari and Firefox. The implementation fronts the tenant with Azure Front Door: add and verify the domain in the external tenant, associate it as a custom URL domain, then route through Front Door.
Critical, easy-to-miss step: after you enable a custom URL domain, the default
<tenant>.ciamlogin.comhost still works. You must block it (Front Door rule / WAF) so attackers cannot bypass your branded, rate-limited front door and hit the raw endpoint directly. I have seen DDoS and credential-stuffing campaigns aimed squarely at the un-fronted default host because teams forgot to close it.
7. Custom authentication extensions: shape attributes and tokens
This is where External ID earns its keep over B2C’s XML. Instead of editing policy files, you register REST callouts bound to specific events. Three matter for CIAM:
- OnAttributeCollectionStart — fires before the sign-up form renders. Prefill from an HR/CRM system, or block known-bad signups.
- OnAttributeCollectionSubmit — fires after the user submits. Validate, normalize, or block based on entered values.
- OnTokenIssuanceStart — fires just before a token is issued. Enrich the token with claims from your own datastore (entitlements, account ID, tier).
Each callout is an Azure Function (or any HTTPS endpoint) that returns a strictly-typed JSON contract. The endpoint is protected by Entra app auth — the platform calls it with a token whose azp/appid is the well-known custom-extensions caller 99045fe1-7639-4a75-9d4a-577b6ca3810f; validate that in your function’s auth config.
Attribute collection submit: validate and normalize
Return a microsoft.graph.onAttributeCollectionSubmitResponseData with an action. To surface a field-level validation error:
{
"data": {
"@odata.type": "microsoft.graph.onAttributeCollectionSubmitResponseData",
"actions": [
{
"@odata.type": "microsoft.graph.attributeCollectionSubmit.showValidationError",
"message": "Please fix the errors below to continue.",
"attributeErrors": {
"city": "City must be at least 3 characters."
}
}
]
}
}
Other submit actions: modifyAttributeValues (silently normalize, e.g. uppercase a postal code), showBlockPage (terminate sign-up and route to a manual-approval queue), and continueWithDefaultBehavior (proceed). The start event mirrors this with setPrefillValues, showBlockPage, and continueWithDefaultBehavior.
Token issuance start: enrich claims
For token enrichment, the callout returns claims under onTokenIssuanceStartResponseData:
{
"data": {
"@odata.type": "microsoft.graph.onTokenIssuanceStartResponseData",
"actions": [
{
"@odata.type": "microsoft.graph.tokenIssuanceStart.provideClaimsForToken",
"claims": {
"loyaltyTier": "gold",
"internalAccountId": "ACC-918273"
}
}
]
}
}
Registering the extension and binding it is two Graph objects. First the extension itself, pointing at your function and authenticating via Entra app token:
POST https://graph.microsoft.com/beta/identity/customAuthenticationExtensions
Content-Type: application/json
{
"@odata.type": "#microsoft.graph.onTokenIssuanceStartCustomExtension",
"displayName": "EnrichWithEntitlements",
"description": "Fetch loyalty tier and account id from billing store",
"endpointConfiguration": {
"@odata.type": "#microsoft.graph.httpRequestEndpoint",
"targetUrl": "https://func-ciam-claims.azurewebsites.net/api/onTokenIssuanceStart"
},
"authenticationConfiguration": {
"@odata.type": "#microsoft.graph.azureAdTokenAuthentication",
"resourceId": "api://func-ciam-claims.azurewebsites.net/<events-api-app-id>"
},
"claimsForTokenConfiguration": [
{ "claimIdInApiResponse": "loyaltyTier" },
{ "claimIdInApiResponse": "internalAccountId" }
]
}
Then a listener binds it to the consuming app, so the enriched claims only flow for the right audience:
POST https://graph.microsoft.com/beta/identity/authenticationEventListeners
Content-Type: application/json
{
"@odata.type": "#microsoft.graph.onTokenIssuanceStartListener",
"conditions": {
"applications": {
"includeAllApplications": false,
"includeApplications": [ { "appId": "<storefront-spa-app-id>" } ]
}
},
"priority": 500,
"handler": {
"@odata.type": "#microsoft.graph.onTokenIssuanceStartCustomExtensionHandler",
"customExtension": { "id": "<customExtensionObjectId>" }
}
}
Latency is now in your auth critical path. The platform calls your function synchronously during sign-in; a slow or down endpoint degrades or blocks login. Keep the function warm (avoid cold starts — Premium or Flex Consumption plan), set tight timeouts, cache the upstream lookup, and fail open for non-essential claims rather than wedging the whole sign-in.
8. Secure the CIAM app: PKCE, scopes, and API protection
CIAM apps are public clients facing the open internet. The non-negotiables:
- Authorization code + PKCE for everything. SPAs and mobile apps use the code flow with PKCE and no secret. Do not enable the implicit flow for production apps —
jwt.mstesting uses it, your storefront must not. - Least-privilege scopes. The SPA requests only what it needs (
openid profile offline_accessplus your API’saccess_as_user). Resource APIs validateaud,iss,scp, and signature on every request. - Refresh token hygiene.
offline_accessgives you rotating refresh tokens; store them in memory/secure storage, neverlocalStoragefor high-value apps. Conditional Access session controls govern lifetime. - Protect the extension endpoints. Validate the incoming token’s issuer and the
99045fe1-...app ID in the Function’s Authentication config so only Entra can invoke your callouts.
A correct authorize request against the external tenant — note the host and PKCE parameters:
GET https://contoso.ciamlogin.com/<tenant-id>/oauth2/v2.0/authorize
?client_id=<storefront-spa-app-id>
&response_type=code
&redirect_uri=https://shop.contoso.com/auth/callback
&scope=openid%20profile%20offline_access%20api%3A%2F%2F<api-app-id>%2Faccess_as_user
&code_challenge=<base64url-sha256-of-verifier>
&code_challenge_method=S256
&state=<opaque>
&nonce=<opaque>
9. Scale, monitor, and migrate from B2C
External ID applies Conditional Access and ID Protection to consumer accounts — risk-based MFA, sign-in risk policies, and the works, which B2C never did natively. Stream sign-in and audit logs to Log Analytics and watch the funnel and the abuse surface with KQL:
// Sign-up / sign-in failures on the external tenant, by IdP and error,
// last 24h -- catch a broken social secret or a credential-stuffing run early
SigninLogs
| where TimeGenerated > ago(24h)
| where ResultType != 0
| summarize Failures = count() by
IdentityProvider = tostring(coalesce(FederatedCredentialId, "local")),
ResultType,
ResultDescription
| order by Failures desc
Watch for: a spike on one IdP (expired social client secret or rotated Apple key), a flood of new-account sign-ups from one ASN (bot registration — tighten the OnAttributeCollectionSubmit block logic and Front Door rate limits), and OnTokenIssuanceStart timeouts (your enrichment function is the bottleneck). Put a WAF rate limit on the custom URL domain front door and, again, block the raw ciamlogin.com host.
Migrating from B2C is a re-platform, not a lift-and-shift, because the customization models are incompatible. The viable path: stand up the external tenant fresh, rebuild policies as user flows + extensions, then migrate users. For seamless password migration without forcing resets, use Microsoft Graph to bulk-create users with the passwordProfile, or — when you cannot export password hashes — do just-in-time migration: on first sign-in, an OnAttributeCollectionStart/Submit (or a pre-auth API) validates the credential against the old store and provisions the account on the fly. Run both tenants in parallel behind your app’s auth config until the JIT tail drains, then decommission B2C.
Verify
Confirm the whole loop end to end, not just that resources exist.
# 1. The external tenant and its primary domain
az rest --method GET \
--url "https://graph.microsoft.com/v1.0/organization?\$select=id,displayName,verifiedDomains"
# 2. The user flow exists and allows sign-up
az rest --method GET \
--url "https://graph.microsoft.com/beta/identity/authenticationEventsFlows"
# 3. Identity providers are registered (look for socialIdentityProvider entries)
az rest --method GET \
--url "https://graph.microsoft.com/v1.0/identity/identityProviders"
# 4. The token enrichment extension and its listener are bound to the app
az rest --method GET \
--url "https://graph.microsoft.com/beta/identity/customAuthenticationExtensions"
az rest --method GET \
--url "https://graph.microsoft.com/beta/identity/authenticationEventListeners"
Then run a real browser sign-up against the authorize endpoint with redirect_uri=https://jwt.ms and confirm in the decoded token that your custom claims (loyaltyTier, internalAccountId) and collected attributes appear. If the enrichment claims are missing, the listener binding or the claimsForTokenConfiguration mapping is wrong — the extension fired but nothing mapped into the token.
Enterprise scenario
A retail platform team launched a customer portal on an External ID external tenant. It worked in test and fell over at peak in production: every few minutes a wave of sign-ins hung for 8-10 seconds and a fraction timed out. Sign-up funnel conversion cratered during morning traffic.
The constraint: their OnTokenIssuanceStart function called the billing system synchronously to stamp loyaltyTier and internalAccountId into the token. It ran on a Consumption plan, so under bursty load it cold-started constantly, and the billing API itself was slow under concurrency. Because the callout is synchronous and in the auth critical path, every cold start and every slow upstream call became a login stall — and the timeout surfaced to the customer as a failed sign-in, not a missing claim.
They fixed it in three moves. First, they moved the function to a plan that keeps instances warm and removed cold starts from the path. Second, they put a short-TTL cache (Redis) in front of the billing lookup keyed by user object ID, since loyalty tier changes hourly at most, not per token. Third — the important architectural call — they made the enrichment fail open: if the lookup misses the SLA, return continueWithDefaultBehavior with no claims rather than blocking, and let the API derive entitlements from a cached projection on its own side.
// Token issuance start: enrich, but never block sign-in on a slow upstream.
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(600));
var claims = new Dictionary<string, object>();
try
{
var entitlement = await _cache.GetOrFetchAsync(objectId, cts.Token); // Redis -> billing
claims["loyaltyTier"] = entitlement.Tier;
claims["internalAccountId"] = entitlement.AccountId;
}
catch (OperationCanceledException)
{
_log.LogWarning("Entitlement lookup exceeded SLA for {oid}; issuing token without enrichment", objectId);
// claims stays empty -> ContinueWithDefaultBehavior, no claims injected
}
var action = claims.Count > 0
? new { odataType = "microsoft.graph.tokenIssuanceStart.provideClaimsForToken", claims }
: (object) new { odataType = "microsoft.graph.tokenIssuanceStart.continueWithDefaultBehavior" };
The lesson generalizes: any custom authentication extension is now part of your login SLA. Cache aggressively, keep the compute warm, set explicit timeouts, and decide deliberately whether each extension fails open or closed. Default to fail-open for enrichment, fail-closed only for genuine security gates.