Entra External ID customer sign-in: user flows, attributes, and claims
For developers wiring a customer-facing app to Entra External ID, this guide explains how user flows, custom attributes, and token claims fit together in a real request path. You’ll see the concrete moving parts, what to configure, what breaks when claims don’t show up, and how to decide whether this is the right identity layer for your app.
TL;DR — Entra External ID for customer-facing identity is the hosted identity layer you put in front of your app when you want sign-up/sign-in, profile collection, and standards-based tokens without building auth yourself. The most common implementation mistake is assuming a custom attribute automatically appears in tokens: it does not unless you both collect/store it in the flow and explicitly emit it as a claim your app requests and validates. Reading time: ~7 min
What it is and where it sits
For a customer-facing app, Entra External ID sits between the browser/mobile client and your application APIs as the OpenID Connect/OAuth 2.0 identity provider. Your app redirects users there for sign-up or sign-in, Entra runs a configured user flow, stores profile data including custom attributes, and returns ID/access tokens back to your app.
What it replaces in a typical stack:
- Your homegrown registration/login pages and password reset logic.
- A lot of account lifecycle plumbing around email verification and profile capture.
- Ad hoc JWT issuance code in your backend.
What it does not replace:
- Your application authorization model.
- Your user-domain data store.
- Your API gateway/session handling.
Typical request/data flow:
[Browser / Mobile App]
|
| 1. GET /login
v
[Your Frontend / Backend-for-Frontend]
|
| 2. 302 to Entra authorize endpoint with policy/user flow
v
[Entra External ID]
|
| 3. Sign-up/sign-in UI, collect attributes, authenticate user
| 4. Issue ID token / auth code / access token
v
[Your Redirect URI]
|
| 5. Exchange code for tokens, validate claims
v
[Your App/API]
|
| 6. Authorize using app roles, tenant data, feature flags
v
[Your Database / Services]
The architecture context that matters in practice:
- Frontend or BFF talks to the authorization endpoint and receives the redirect.
- Token endpoint is called by your confidential client backend or SPA helper library, depending on app type.
- Your API should trust only validated tokens whose
iss,aud, signature, expiry, and expected policy/user-flow context match what you configured. - Custom attributes live in the identity directory, not in your app DB. Treat them as profile/identity metadata, not as your source of truth for mutable business state.
If you already know Azure AD B2C: conceptually this is the same problem space. The important developer mental model is still “hosted CIAM with policies/user flows that shape UX and claims.”
How it actually works
Walk one realistic example: a SaaS app needs customer sign-up/sign-in, must collect a loyaltyId during registration, and wants that value in the ID token so the app can pre-link the user to an existing customer record.
Step 1: Register the app and redirect URI
In the Entra admin experience for your tenant, create an application registration for your web app and add the exact redirect URI, for example:
https://app.example.com/auth/callback
If the redirect URI is wrong by even scheme, host, port, path, or trailing slash, the authorize request fails before your app sees anything useful.
A misconfigured redirect usually looks like this from the browser network tab: the authorize request returns an error page, or your app loops back to login.
If you inspect the first hop manually:
curl -I "https://<tenant-authority>/oauth2/v2.0/authorize?...&redirect_uri=https%3A%2F%2Fapp.example.com%2Fauth%2Fcallback%2F"
Typical failure shape when the registered URI was .../auth/callback but you sent .../auth/callback/:
HTTP/2 400
content-type: text/html; charset=utf-8
cache-control: no-store
x-ms-request-id: 7c1d....
x-ms-correlation-id: 1a92....
The body usually names a redirect URI mismatch. Save the correlation ID; it is what support and tenant logs key off.
Step 2: Create a user flow for sign-up/sign-in
Create a combined sign-up/sign-in user flow. In that flow, choose the attributes to collect during sign-up. Add your custom attribute loyaltyId and mark it as collected.
This is the first place teams get tripped up: collecting an attribute in the flow stores it on the user object, but does not automatically put it into every token.
Step 3: Configure token claims emission
In the same identity setup, configure the application or user flow token settings so loyaltyId is emitted as a claim in the ID token if your app needs it client-side, and in the access token only if your API actually needs it.
Prefer this rule:
- Put profile/display data in the ID token if the client needs it immediately after login.
- Put only API-relevant claims in the access token.
- Do not stuff large profile blobs into either token; token size becomes a real operational problem with proxies, cookies, and headers.
Step 4: Start auth with the user flow/policy in the authority
Your app sends the user to the authorize endpoint using the authority that includes the user flow. In a Node/Express app using MSAL, that means your authority is not just the tenant root; it points at the specific flow.
The request includes:
client_idredirect_uriresponse_type=codescope=openid profile offline_access ...- PKCE if applicable
- authority/user-flow identifier
Step 5: User signs up, attribute is stored
The hosted page asks for email/password or federated sign-in, plus loyaltyId because you configured it in the flow. After successful sign-up, Entra persists the user and returns an auth code to your redirect URI.
Step 6: Your backend exchanges the code and validates the token
Your backend exchanges the code for tokens, validates signature and standard claims, then reads loyaltyId from the ID token.
At this point, three common failure modes appear:
- Claim missing: the attribute exists on the user object but was not configured as an emitted claim.
- Wrong token inspected: developers look for the claim in the access token when they only configured it in the ID token.
- Stale session: you changed the flow/claims config, but you are still looking at an old token from an existing browser session.
A decoded ID token payload should look roughly like:
{
"aud": "11111111-2222-3333-4444-555555555555",
"iss": "https://<authority>/.../v2.0/",
"iat": 1790841000,
"exp": 1790844600,
"name": "Ada Lovelace",
"sub": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"oid": "ffffffff-1111-2222-3333-444444444444",
"loyaltyId": "CUST-204918"
}
Step 7: Your app links or provisions the customer record
Your app now looks up loyaltyId in its own database. If found, it links the identity subject (sub/oid) to the customer record. If not found, it can create a pending account or reject access depending on your business rule.
Important: do not use mutable claims like display names or marketing preferences as authorization inputs. Use stable identifiers and your own DB for entitlements.
When to use it (and when not to)
Use it when you want hosted customer identity with standards-based tokens and configurable sign-up/sign-in flows, but you still want your app to own business authorization.
| Scenario | Recommendation |
|---|---|
| Public customer app needs email/password or social/federated sign-in, self-service sign-up, password reset | Use Entra External ID with user flows |
| You need to collect a few profile fields at registration and expose them to the app as claims | Use custom attributes plus explicit token claim mapping |
| You need deep, highly bespoke identity orchestration across many external systems at sign-in time | User flows may be too limiting; evaluate whether you need more advanced policy/extensibility patterns |
| Internal workforce SSO for employees only | You probably don’t need customer-facing External ID; use workforce identity features instead |
| Your app only needs a simple magic-link login and no directory-backed user profiles | You probably don’t need this; a lighter auth system may be cheaper and simpler |
| You want app roles/permissions to change instantly without token refresh | Don’t rely on token claims alone; use app-side authorization checks against your DB or an authorization service |
“You probably don’t need this if…” checklist:
- You have one small app, no federation needs, and no compliance pressure around account lifecycle.
- You are tempted to put all customer profile and entitlement state into directory attributes.
- You need sub-second propagation of authorization changes across already-issued tokens.
Trade-offs
Every benefit here has a cost.
-
Benefit: hosted sign-up/sign-in UX and credential handling
Cost: you inherit the provider’s flow model and naming, and debugging often means correlating browser redirects, token contents, and tenant configuration rather than stepping through your own code. -
Benefit: custom attributes in the directory
Cost: schema decisions become identity decisions. Renaming, repurposing, or overloading attributes later is painful. Keep them sparse and stable. -
Benefit: claims-based integration is clean for apps and APIs
Cost: token bloat, stale claims until refresh, and subtle bugs when teams confuse ID tokens with access tokens. -
Benefit: standards-based OIDC/OAuth interoperability
Cost: you still need to understand redirect URI exactness, PKCE, nonce/state handling, issuer/audience validation, and logout behavior. Hosted auth does not remove protocol sharp edges. -
Benefit: central identity for multiple apps
Cost: stronger vendor coupling. Your app code, tenant config, and operational runbooks all become tied to this identity platform’s concepts and admin surface. -
Benefit: less auth code in your app
Cost: more operational dependency on tenant configuration changes. A bad claim mapping or flow edit can break login without any app deploy.
In practice
Example 1: Node.js web app using MSAL with a sign-up/sign-in flow
import express from "express";
import session from "express-session";
import { ConfidentialClientApplication } from "@azure/msal-node";
const app = express();
app.use(session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false }));
const policy = process.env.ENTRA_USER_FLOW; // e.g. B2C_1_signup_signin
const tenantDomain = process.env.ENTRA_TENANT_DOMAIN; // e.g. contoso.ciamlogin.com or your tenant authority host
const tenantName = process.env.ENTRA_TENANT_NAME; // e.g. contoso.onmicrosoft.com
const msal = new ConfidentialClientApplication({
auth: {
clientId: process.env.CLIENT_ID,
clientSecret: process.env.CLIENT_SECRET,
authority: `https://${tenantDomain}/${tenantName}/${policy}`
}
});
app.get("/login", async (req, res) => {
const authCodeUrlParameters = {
scopes: ["openid", "profile", "offline_access"],
redirectUri: "https://app.example.com/auth/callback"
};
const url = await msal.getAuthCodeUrl(authCodeUrlParameters);
res.redirect(url);
});
app.get("/auth/callback", async (req, res, next) => {
try {
const tokenResponse = await msal.acquireTokenByCode({
code: req.query.code,
scopes: ["openid", "profile", "offline_access"],
redirectUri: "https://app.example.com/auth/callback"
});
req.session.user = {
sub: tokenResponse.idTokenClaims.sub,
loyaltyId: tokenResponse.idTokenClaims.loyaltyId,
name: tokenResponse.idTokenClaims.name
};
res.redirect("/");
} catch (err) {
next(err);
}
});
What it does: redirects to the configured user flow, exchanges the auth code, and reads loyaltyId from the ID token claims. Gotcha: if loyaltyId is undefined, first check the flow/app claim emission config before blaming MSAL.
Example 2: Inspect the returned token and diagnose a missing custom claim
TOKEN="eyJ..."
printf '%s' "$TOKEN" | awk -F. '{print $2}' | tr '_-' '/+' | base64 -d 2>/dev/null | jq
Typical output when the claim is present:
{
"aud": "11111111-2222-3333-4444-555555555555",
"iss": "https://<authority>/.../v2.0/",
"sub": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"name": "Ada Lovelace",
"loyaltyId": "CUST-204918"
}
Typical output when the claim is missing:
{
"aud": "11111111-2222-3333-4444-555555555555",
"iss": "https://<authority>/.../v2.0/",
"sub": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"name": "Ada Lovelace"
}
What it does: decodes the JWT payload locally so you can verify whether the claim was actually issued. Gotcha: this does not validate the signature; use it only for inspection, not trust.
Example 3: Backend claim validation guardrail
{
"requiredClaims": {
"iss": "https://<authority>/.../v2.0/",
"aud": "11111111-2222-3333-4444-555555555555"
},
"optionalProfileClaims": ["name", "loyaltyId"],
"authorizationClaimsNotTrusted": ["name", "emails", "loyaltyId"]
}
What it does: documents a sane validation boundary for your service. Gotcha: teams often drift into using profile claims for authorization because they are “already in the token”; resist that and check entitlements in your own store.
⚠️ If you change redirect URIs, authority values, or claim mappings in production, active login flows can fail immediately and existing sessions may behave inconsistently until tokens refresh. Roll changes behind a maintenance window or deploy a second app registration/flow for cutover testing.
Further reading
- Microsoft identity platform: OpenID Connect and OAuth 2.0 protocol docs
- Entra External ID documentation: user flows and custom attributes sections
- OAuth 2.0 Authorization Framework (RFC 6749)
- OpenID Connect Core 1.0
- JSON Web Token (RFC 7519)
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI