Native Authentication with Microsoft Entra External ID: The Practical Guide I Wish I'd Found
Microsoft rebranded Azure AD B2C into Entra External ID, shipped a new Native Authentication API, and left engineers without practical writing on wiring it into a real app. This is that field guide: why native auth, the gotchas, token/session lifecycle, and where enforcement belongs.
Microsoft rebranded Azure AD B2C into Entra External ID, shipped a new Native Authentication API, and then left engineers mostly without practical writing on how to wire it into a real app. This is the field guide I needed: why you'd choose native auth, the gotchas that cost me hours, how to handle tokens and sessions, where to put enforcement, and one realization that simplified my whole design.
One clarification first: "native auth" doesn't mean reimplementing OAuth. It means you own the login UI and exchange credentials with Entra through a server-side API instead of a browser redirect. You orchestrate a multi-step protocol; Entra still issues the tokens.
Why native auth instead of redirect flows
The default OIDC pattern redirects the user to a Microsoft-hosted login page and brings them back with a code you exchange for tokens. It's secure and well-trodden, and for many apps it's correct.
Native authentication is the opposite trade: you own the entire login experience — your own fields, validation, and error states — and nobody ever leaves your domain. No Microsoft-branded interstitial, no round-trip, no theming someone else's page through a restricted system. You want this when login is part of the product, when you control onboarding end-to-end, and when email/password is the primary factor. The cost is that you now orchestrate the protocol yourself.
The mental model: a multi-step token handshake
The most important thing to internalize is that native sign-in is not one call. It's a sequence, and each step hands you a short-lived continuation token you pass into the next:
initiate → continuation token
challenge → continuation token
token → access / refresh / id tokensinitiate says who's signing in, challenge requests the password challenge, token exchanges the password for tokens. Treat the continuation token as opaque, single-use, and time-boxed — don't inspect it, don't persist it, just forward it.
Critically, all of this runs server-side. The browser never talks to Entra and never sees a continuation token. The password is forwarded from your server to Entra and never stored or logged. Register the app as a public client — native auth uses no client secret. If you're reaching for a secret, you've misconfigured the registration.
Server-side orchestration as the seam
The multi-step choreography doesn't belong in components. I put it behind server-side operations that expose coarse, intent-level actions — "start," "verify code," "submit password" — while hiding the step ordering and token threading inside. The "start" operation, for instance, internally performs both the initiate and the challenge call, so the client makes one request and gets back what it needs to prompt for a code. This is the single highest-leverage structural decision: the protocol's complexity lives in one server-side place, and the rest of the app speaks in intentions.
The realization: invitation and password reset are the same flow
Here's what simplified my whole design. "A new user sets their initial password from an invitation link" and "an existing user resets a forgotten password" feel like two features. They're not. Both are Entra's self-service password reset (SSPR) flow:
start → challenge → continue (verify OTP) → submit (new password) → poll completionAn admin creates a user without a password; the invitation flow simply runs SSPR to let them set one. Forgot-password runs the identical sequence. The only real difference is the copy ("Welcome, set your password" vs. "Reset your password"). Once I saw that, two features collapsed into one orchestration with two thin UX wrappers. If you're building onboarding and recovery separately, stop — they're one flow.
The submit step also taught me that SSPR completion is asynchronous. After submitting the new password you poll for completion in a bounded loop until you get succeeded or failed, with a short delay between attempts and a defined decision for the timeout case. Don't assume the password is live the instant you submit it.
The gotchas that actually cost me time
The base URL is not the B2C URL. External ID uses *.ciamlogin.com, and unlike B2C there is no ?p=<policy> parameter — the token endpoint is standard OIDC. Leftover policy params from old B2C snippets will silently break you.
"Force change password" is an error that isn't one. When an admin creates a user with a temporary password, the first sign-in's token call returns an error response — but with a sub-error like "password expired" and a fresh continuation token. That's not a failure; it's Entra telling you to route the user into a set-your-own-password flow with that token. Treat every error as "wrong password" and brand-new users can never sign in. Detect this case explicitly and branch.
Read the numeric error codes, not just the error string. Entra returns a generic error plus a far more precise array of error codes. Map them to human messages — wrong credentials, no such user, disabled, locked from too many attempts — and to specific password-policy rejections on reset (too weak, too short, recently used, banned). Build one mapping function early; you'll extend it constantly.
Handle the redirect challenge type. Even in native auth, initiate can respond that a given identity (a federated/social account) must use a web-based flow. You can't password-prompt past it — detect it and fall back gracefully.
Tokens and sessions
Native auth gets you Entra's tokens; the lifecycle is yours. The pattern that's served me is a JWT-backed session with a three-layer expiry (the exact durations are illustrative — tune to your tenant):
- Access token — short-lived (~1h), used for API calls.
- Refresh token — medium-lived, mints new access tokens.
- Absolute expiry — a hard ceiling (e.g. 90 days) after which the user must fully re-authenticate, no matter what.
Refresh proactively — a few minutes before the access token expires, not reactively on a 401. Support refresh-token rotation: adopt a new refresh token if Entra returns one, keep the old one otherwise. And because you're a public client, send no secret on refresh — Entra rejects it. When refresh fails, mark the session with an explicit error state so the boundary layer can act on it.
One non-obvious lesson: fetch your app-specific user profile at sign-in and bake it into the first session token. If your enforcement needs role or tenant data on the very first request and you fetch it lazily after the cookie is set, you hit a race where the first protected navigation sees an incomplete session and wrongly rejects the user. Pre-populating the token — and flagging it as fetched so you don't re-fetch every request — closes that gap.
Where enforcement lives: two tiers, not one
This is the structural detail I most want to pass on. It's tempting to make the middleware do everything. Don't. Split it:
- Middleware does authentication only — is there a valid session? It allows public routes, and on a session marked expired or with a failed refresh it clears the session cookies and redirects to sign-in with an expired flag; unauthenticated users are redirected with their original destination preserved as a callback URL so they land where they meant to after logging in.
- Page-level guards do authorization — is this user allowed in this tenant / this resource? Tenant-scoped access checks live at the page boundary, not in the middleware.
Keeping "are you signed in?" and "are you allowed here?" in separate tiers means the middleware stays simple and fast, and authorization lives next to the resources it protects, where the context to make the decision actually exists.
What I'd tell an engineer starting out
Do everything server-side and register a public client — those two decisions prevent whole categories of mistakes. Put the multi-step choreography behind intent-level server operations. Realize early that invitation and password reset are one SSPR flow. Treat continuation tokens as opaque and ephemeral, and SSPR completion as asynchronous. Build your error-code map on day one. Explicitly handle the three invisible branches — force-change-password, federated redirect, account-locked — and test with a freshly admin-created temp-password account, because that's the path tutorials skip. Split authentication from authorization across middleware and page guards. And design the token lifecycle — proactive refresh, rotation, absolute ceiling — before you ship.
How I'd evolve this
The implementation works, but the durable question is what it becomes as the surface area and stakes grow. Each direction is the same move: take something handled by convention and make it an explicit, verifiable guarantee.
Express the flows as one parameterized state machine. Sign-in and SSPR are sequences of states with well-defined transitions and a handful of non-obvious branches (password-change-required, federated-redirect, locked, async-completion). Encoding them as explicit state machines — rather than chained conditionals — makes a missed branch a visible gap instead of a silent fall-through, and makes the whole flow describable to someone who's never read the code. The invitation-equals-reset insight is the first instance of a broader principle: collapse flows that share a state machine, and vary only the surface.
Make the token lifecycle a verified policy. Proactive refresh, rotation, and the absolute ceiling are correctness-critical and notoriously hard to observe failing. Express them as a policy with tests asserting the invariants — that a session past its ceiling cannot be revived, that a failed refresh always terminates cleanly, that rotation is honored. Security-sensitive timing should be proven, not hoped.
Treat the two enforcement tiers as one declared model. Authentication in middleware and authorization at the page boundary is the right split, but the rule for which tier owns which decision currently lives in developers' heads. Making that ownership explicit — and verifying that no protected route can be reached without passing both tiers — turns a convention into a checkable property, so a new route can't accidentally skip a layer.
Own the provider contract at a single boundary. Entra's quirks — the CIAM base URL, the numeric codes, the sub-error semantics, the async completion — are the vendor's, but the meaning your app assigns them is yours. Concentrating that translation at one seam means a new error code, a provider change, or a future migration touches one well-understood place instead of rippling outward. The deepest lesson of integrating any identity provider is that your job isn't to scatter its vocabulary through your codebase — it's to translate it, once, into your own.
The throughline: own the experience, but contain the provider — keep the protocol behind intent-level server operations, collapse flows that are secretly the same flow, split authentication from authorization cleanly, and treat the security-critical timing as something you continuously verify rather than quietly trust.