Entra External ID: user flows, custom attributes, and token claims
This guide is for developers wiring customer sign-up/sign-in into apps with Entra External ID and needing more than marketing diagrams. It shows where user flows fit, how custom attributes become token claims, what breaks in real integrations, and how to decide whether this is the right tool at all.
TL;DR — Entra External ID for customer-facing identity is the hosted identity layer that sits between your app and your user store, issuing OIDC/OAuth tokens after a configured user flow runs. The practical key takeaway: design your token contract first, then create only the custom attributes and user flows needed to populate those claims; most integration pain comes from missing claims, wrong redirect URIs, and assuming every profile field automatically appears in tokens. Reading time: ~7 min
What it is and where it sits
For customer-facing apps, Entra External ID is the identity front door: your SPA, mobile app, or server-side web app redirects users to the provider-hosted sign-in/sign-up experience, and your app gets back tokens. In practice, it replaces the parts teams often regret building themselves: registration screens, password reset, social/federated sign-in plumbing, email verification, and policy-driven token issuance.
The important architecture point is this: user flows are not your app logic. They are hosted identity workflows that collect input, validate identity, and decide what goes into the resulting token. Custom attributes are profile fields stored for the user. Token claims are what your app actually receives. Those three are related, but not interchangeable.
Typical request path:
Browser / Mobile App
|
| 1. GET /login
v
Your App / API Gateway
|
| 2. 302 to OIDC authorize endpoint with policy/user flow
v
Entra External ID hosted auth pages
|
| 3. Sign-up/sign-in/password reset/social login
| 4. Read/write user profile incl. custom attributes
v
Token service
|
| 5. ID token / access token returned to redirect_uri
v
Your App
|
| 6. Validate token, map claims to app authz/session
v
Your API / DB
What talks to it:
- Your frontend via OIDC authorization code flow with PKCE.
- Your backend/API via JWT validation against issuer metadata and JWKS.
- Your provisioning/admin tooling if you later need to read or update customer profile data.
Where it lives in a typical flow:
- Before your application session exists.
- Outside your app runtime for credential handling.
- Upstream of your authorization layer; your app still decides what a role/plan/tenant means.
The mental model that avoids bad designs: External ID authenticates and emits claims; your app authorizes and persists app-specific state. Do not turn token claims into your primary customer database.
How it actually works
Walk one realistic example: a B2C SaaS app wants users to sign up with email/password or Google, collect country and marketingConsent, and include those values in the ID token so the app can personalize onboarding.
Step 1: Register the app and define redirect URIs
Your app initiates OIDC login against the tenant-specific authorization endpoint. The redirect URI must match exactly, including scheme, host, path, and trailing slash behavior.
A classic failure looks like this in a browser-network trace or curl -I against your app callback when the app never receives the code:
curl -I "https://app.example.com/auth/callback?code=abc&state=xyz"
HTTP/2 400
content-type: text/plain; charset=utf-8
x-request-id: 7d4f1b2c
content-length: 72
invalid_state: OIDC callback received but no matching login transaction found
That error is usually your app session/cookie problem. By contrast, if the identity provider rejects the redirect URI before sending the user back, you typically see an authorization error page and your app callback is never hit. The root cause is usually an exact redirect URI mismatch.
Step 2: Create custom attributes
Create only the profile fields you need the identity system to own. In this example:
countryas a string-like profile field.marketingConsentas a boolean-like profile field.
These attributes live on the customer profile. They are not automatically in tokens just because they exist.
Step 3: Configure a user flow
Create a sign-up/sign-in user flow that does three things:
- Presents local account and/or social identity options.
- Collects the custom attributes during sign-up or profile edit.
- Emits selected claims in the token.
The concrete admin action is done in the Entra admin center for your External ID tenant: create a customer sign-up/sign-in user flow, then in that flow configure user attributes to collect and application claims to return. UI labels move around over time, but the concepts are stable: one section controls what the page asks the user; another controls what the token contains.
If you skip step 3.3, your app will never see the values even though the profile stores them.
Step 4: App sends the user to the authorize endpoint
Your app redirects to the authorize endpoint with:
client_idredirect_uriresponse_type=codescope=openid profile offline_access ...code_challengeandcode_challenge_method=S256- the selected user flow/policy in the path or request, depending on endpoint shape
The user completes sign-up. During this, the hosted page writes country and marketingConsent to the user profile.
Step 5: Token issuance maps profile data to claims
After successful auth, the token service issues an ID token. If the user flow is configured to emit the custom attributes, the token payload shape is roughly:
{
"iss": "https://<tenant-authority>/...",
"aud": "11111111-2222-3333-4444-555555555555",
"sub": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"name": "Ada Lovelace",
"emails": ["ada@example.com"],
"country": "DE",
"marketingConsent": true,
"tfp": "B2C_1_signupsignin",
"exp": 1780000000,
"iat": 1779996400
}
The exact claim names vary by configuration and provider conventions. Do not hardcode assumptions from a screenshot; inspect a real token from your tenant.
Step 6: Your app validates and uses claims
Your backend validates:
- issuer
- audience
- signature via JWKS
- expiry/not-before
- optional policy/user-flow claim if your app depends on a specific flow
Then your app maps claims into app behavior. Example:
country=DE→ show EU VAT onboarding branchmarketingConsent=true→ enable marketing email preference, but still persist a server-side audit record if compliance matters
Important edge case: if a returning user signed up before you added a new custom attribute, the claim may be absent until the user goes through a flow that collects or updates it. Your code should handle missing claims without crashing.
When to use it (and when not to)
Use it when you want hosted customer identity and token issuance without owning credential workflows. Do not use it as a substitute for your authorization model or customer master data.
| Scenario | Recommendation |
|---|---|
| SaaS app needs sign-up/sign-in, password reset, social login, and OIDC tokens | Use Entra External ID with user flows |
| You need a few profile fields during registration and in tokens | Use custom attributes plus claim mapping |
| You need highly specialized registration logic, external risk engines, or unusual orchestration | Check whether user flows are sufficient; if not, this may be the wrong fit |
| You only have workforce/internal users | You probably don’t need customer-facing External ID; use workforce identity instead |
| Your app needs complex fine-grained authorization based on org/project entitlements | Use External ID for authn, but keep authz in your app/API |
| You want a customer profile database with reporting, search, and transactional history | Don’t use token claims or identity profile as your system of record |
| You need zero hosted redirect UX and want full control over every screen and API | You probably don’t need this if your requirement is fully bespoke identity UX and logic |
A good rule: if your question starts with “Can the token include…”, you are in the right area. If it starts with “Can the identity provider become our CRM/billing/authorization engine…”, you are probably stretching it.
Trade-offs
Every benefit has a bill attached.
-
Benefit: faster delivery of sign-up/sign-in/reset flows
Cost: you accept hosted-flow constraints, provider-specific configuration, and some UX limits. -
Benefit: fewer credential-handling risks in your app
Cost: your login path now depends on external availability, external cookies/redirects, and correct OIDC configuration. -
Benefit: custom attributes can travel in tokens
Cost: token bloat, stale data risk, and pressure to misuse claims as a database. Keep claims small and stable. -
Benefit: social/federated sign-in support
Cost: more moving parts during onboarding and support. Identity linking and account recovery edge cases get harder. -
Benefit: centralized identity policy
Cost: lock-in to provider terminology and behavior. Migrating user flows and claim contracts later is not free. -
Benefit: less app code for authn
Cost: more operational knowledge required around redirect URIs, issuer metadata, JWKS caching, cookie state, and browser privacy behavior.
Latency-wise, expect one more network round-trip-heavy redirect dance during login. For APIs after login, the cost is mostly token size and validation overhead, which is usually small if you cache JWKS correctly.
In practice
Example 1: OIDC authorize URL you can test directly
AUTHORITY="https://<tenant-authority>"
CLIENT_ID="11111111-2222-3333-4444-555555555555"
REDIRECT_URI="http://localhost:3000/auth/callback"
SCOPE="openid profile offline_access"
CODE_CHALLENGE="Z_P4EKbGwIkA01e3Y5fp4tMCvn_Ae5nUw7qY7XwkTrQ"
STATE="debug-state-123"
NONCE="debug-nonce-123"
printf '%s
' "${AUTHORITY}/oauth2/v2.0/authorize?client_id=${CLIENT_ID}&response_type=code&redirect_uri=${REDIRECT_URI}&scope=${SCOPE}&code_challenge=${CODE_CHALLENGE}&code_challenge_method=S256&state=${STATE}&nonce=${NONCE}"
This prints a browser-testable authorize URL for a local app. Gotcha: redirect_uri and scope must be URL-encoded in real code; hand-built URLs often fail because http://localhost:3000/... is sent unescaped.
Example 2: Validate claims in Node.js with explicit checks
import * as jose from 'jose';
const issuer = 'https://<tenant-authority>/v2.0/';
const audience = '11111111-2222-3333-4444-555555555555';
const jwks = jose.createRemoteJWKSet(new URL(`${issuer}discovery/v2.0/keys`));
export async function validateIdToken(idToken) {
const { payload, protectedHeader } = await jose.jwtVerify(idToken, jwks, {
issuer,
audience
});
if (!payload.tfp || payload.tfp !== 'B2C_1_signupsignin') {
throw new Error(`unexpected_user_flow: got ${payload.tfp ?? 'missing'}`);
}
return {
subject: payload.sub,
email: Array.isArray(payload.emails) ? payload.emails[0] : undefined,
country: payload.country ?? null,
marketingConsent: payload.marketingConsent ?? false,
alg: protectedHeader.alg
};
}
This verifies signature, issuer, audience, and the user-flow claim before mapping custom claims into app fields. Gotcha: don’t assume emails, country, or marketingConsent exist for every user; older accounts and social providers often produce sparse claims.
Example 3: Decode a token payload during debugging
ID_TOKEN='eyJhbGciOi...'
printf '%s' "$ID_TOKEN" | cut -d '.' -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq .
This lets you inspect whether the custom attributes are actually present in the token your app received. Gotcha: decoding is not validation; a pretty payload proves nothing about signature or issuer.
If token claims are missing, debug in this order:
- Confirm the user flow collected the attribute.
- Confirm the same user flow is configured to emit the claim.
- Confirm your app is invoking that exact flow, not a different sign-in policy.
- Decode a real token and inspect payload.
- Only then debug app-side claim mapping.
⚠️ Changing claim output or flow selection can break production login and downstream authorization immediately. Before editing a live user flow, test with a separate app registration or staging redirect URI and decode tokens from both old and new flows side by side.
A final practical recommendation: keep your token contract boring. Stable identifiers, email if needed, and a small number of custom claims that truly affect request-time behavior. Everything else belongs in your app database, fetched after login.
Further reading
- Microsoft identity platform OpenID Connect and OAuth 2.0 protocol docs
- JSON Web Token (JWT) — RFC 7519
- OpenID Connect Core 1.0
- OAuth 2.0 for Browser-Based Apps
- MDN Web Docs — HTTP cookies and Set-Cookie
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