Fair warning before you settle in: this is a long one. Grab a coffee.
We have a school transport app called RADAR, which drivers use to sign in to their shift on a tablet or phone. Some of these drivers are on our SESTA routes, which means they're transporting children under a Ministry of Education contract. The way they were signing in was quietly making their day worse, and it took me a while to properly understand why, let alone what to do about it.
The Invite Nobody Could Accept
Drivers were signing in as what Microsoft calls Entra B2B guests. In plain terms, our system doesn't create these drivers a proper account of its own. Instead it invites their personal email address in as a "guest," a bit like being added as a guest editor on someone else's document rather than getting your own account on the platform. To use the app at all, a driver first has to receive that invitation email and click through to accept it.
That first step alone was a genuine barrier for a meaningful number of our drivers. An unfamiliar email from a company account, asking them to "accept an invitation" before they've even opened the app they were told to use, is exactly the kind of thing that trips people up, and reasonably so. It looks like the sort of email you're supposed to be suspicious of.
Assuming they got past that, the next problem was multi-factor authentication, meaning a second proof of identity beyond just a password. I already knew, in the abstract, that MFA can vary by provider, that's not the part I got wrong. What I got wrong was assuming MFA itself was common enough by now that it wouldn't trip anyone up, the way most people just know how to ride a bike. Nearly every service asks for it these days, so I figured it was safely in the "everyone already gets this" bucket. I was wrong, and if I'm honest with myself, that's less a technical miss and more a sign of how far I've drifted into the engineering side of things over the years. Somewhere along the way I stopped thinking like the person actually using what I build, and started assuming everyone's had roughly the same run of technology I have. They haven't.
The actual mechanism matters here, because it wasn't Entra automatically handing authentication off to whoever a driver's email happened to be with, we never set up federation with Google, Microsoft, or anyone else. It was a decision we'd made ourselves: for guests on Gmail, Hotmail, or Outlook, we skipped our own MFA challenge, trusting those providers to run their own account-security checks rather than layering another one on top. That trust wasn't a belief that every such account definitely had MFA turned on, Google and Microsoft both treat two-step verification as something a person opts into, not something on by default. It was closer to assuming a big provider's own account-security signals, whatever they were for that specific account, were good enough not to duplicate. For everything else, Proton, a personal domain, our own MFA still applied. It felt like a reasonable trust decision at the time, lean on a big provider's own security rather than duplicate it.
The problem is that trusting a provider's own account security also means trusting whatever that specific account happens to be configured with, and we had no visibility into that at all.
And that's where it got strange. The clearest case, and the one that made the whole problem obvious, was a driver on Gmail whose code was landing on a daughter's email address, one we'd never had on file and had no way to have had on file. We don't manage that Google account. We never invited that email, never saw it, never could have known it existed. Whatever was challenging that sign-in wasn't anything of ours, it was entirely inside an account we'd chosen to trust without being able to see into it. Other drivers had it slightly differently, security info pointing at a family member's phone rather than an unknown email, or simply lost access to whatever number or inbox their account's security depended on, with no way back in at all. From their side none of this looked broken day to day, they could open their email fine on their own phone or laptop, they just had no idea their account's actual security check pointed somewhere else entirely, until a sign-in that specifically triggered it, like this one, surfaced it. And a meaningful number of our drivers don't carry a smartphone at all, which quietly rules out a chunk of the standard troubleshooting steps before I've even started.
The thing I had to get straight in my own head before doing anything else: this wasn't a bug. Every part of it was working exactly as designed. Guest invites and provider-dependent MFA are both completely normal, sensible behaviour for a system built to let external people access shared documents. Tuning it further wasn't going to give us the one thing we actually needed, a consistent, tenant-controlled account and MFA model, the same for every driver regardless of which email they'd shown up with. The system needed to be a different kind of thing.
And because RADAR holds student information under our MOE obligations, whatever I proposed instead needed to hold up to real scrutiny. Not "this is more convenient," but something that could stand next to "this is at least as defensible from a security standpoint as what we had before." That constraint shaped almost everything that follows.
How we ended up on guest accounts in the first place
Guest accounts weren't the obvious starting point either, and how we got there explains a few things that come up later. The very first idea, back when RADAR was originally being built, was the simplest one on paper: give every driver a proper Entra account, the same kind our own staff have. It's the fastest route to authentication there is, Microsoft's whole identity stack is built around exactly that. It didn't survive contact with reality, though not for the reason you might expect, account creation and deactivation itself was never going to be the problem, our onboarding and offboarding is automated, so a large, high-turnover driver population doesn't turn into an admin burden the way it might elsewhere. The real issue was cost. Every driver on a proper staff account means real, ongoing per-seat licensing at that scale, and I'm not close enough to the contract to speak to the specifics, but it's fair to say that kind of cost makes the economics of the arrangement noticeably harder to justify.
Once we were on guest accounts, our MSP's reaction was blunt and, in hindsight, entirely fair: "well, who's going to manage this." Someone has to send each invite, keep track of who's still an active driver, and remove the ones who've left, and guest accounts don't come with an easy, self-service way to do any of that. That question didn't quietly resolve itself either. I ended up answering it myself later on, by building a small API endpoint with its own simple screen inside RADAR, so guest accounts could be added and removed by our own team directly, without needing an MSP ticket for every single change. Not glamorous, but it's the piece that actually made guest accounts survivable day to day, and it's the reason "who manages this" wasn't a question I let slide unanswered again when I started looking at replacing the whole thing.
How I Actually Found Entra External ID
This actually got moving because of a hackathon, not a scheduled piece of work. We'd been getting a steady stream of tickets from drivers stuck on the MFA step, and our ops team had no real way to handle them, it's not a standard, teachable fix, it's a different investigation every time, and ops isn't IT. My director and the ops director both landed on the same question independently: can we just simplify this. Their first suggestion was a plain username and password, no MFA at all. My director and I drew a hard line on that one. We're touching PII on RADAR, and dropping the second factor wasn't something either of us was willing to sign off on, however much it would have shortened the support queue.
My first instinct was Auth0. Why Auth0 specifically? I typed something like "third party authentication provider" into Google, and Auth0 was the first result, that was the entire research process at that stage. My boss had actually used Auth0 on a previous project, so at least someone I trusted had kicked the tyres on it already, which made it feel like a reasonable place to start rather than a name I'd pulled out of thin air.
It took actually sitting down with the implications, rather than just the feature list, to talk myself out of it. Introducing Auth0 means introducing a brand new vendor into a security conversation that already involves the Ministry of Education, plus an ongoing per-user cost on top of licensing we already carry elsewhere. It's not that it wouldn't work. It's that "wouldn't it be simpler to stay inside the Microsoft relationship we already have and already have to defend" is a genuinely better question to be asking first, and I hadn't asked it yet.
Where this actually got interesting was a conversation with a mate of mine, a product manager whose own team was independently circling the same kind of problem for a different app. He mentioned his chapter lead had been looking at SecureAuth, another third-party identity vendor I'd genuinely never come across. Curious how it stacked up against Auth0, more out of idle interest than any real plan, I asked an AI to walk me through the difference between the two.
That throwaway question is the actual reason I ever heard of Microsoft Entra External ID at all. It hadn't turned up anywhere in my own research to that point, which isn't all that surprising, it's a genuinely newer addition to a Microsoft product family that Azure AD B2C had occupied for years before it, and it hadn't crossed my radar through Microsoft's own channels yet. Credit where it's due on this one: it was an AI comparing two products I'd asked about out of curiosity that actually put the real answer in front of me, not any research I'd done myself.
That whole conversation happened on a Tuesday night, not a scheduled work session, a hackathon I'd more or less talked myself into. I built the first proof of concept that same night, on my own personal Azure tenant, specifically so I could walk in the next morning and say "yup, I think I have a way," rather than pitch an idea I hadn't actually tested. That working proof of concept is what turned into the MSP ticket to get a real tenant provisioned, and eventually into deploying to a dev environment where I could confirm sign-in and the SMS second factor worked end to end. Authorisation, it turned out, had its own surprise waiting, more on that later in this post.
Why Entra External ID, Specifically
One alternative worth a mention before moving on: Entra's frontline worker features, Microsoft's own purpose-built answer for shared devices and frontline staff, inside our existing staff directory. On paper it looked like the obviously correct Microsoft-native choice. In practice it still requires converting every driver into a licensed member of our staff tenant, on Microsoft's dedicated Frontline Worker licensing tier rather than our normal employee tier, cheaper per seat than a standard staff licence, but not free. Multiply that per-seat cost across a large, high-turnover population who aren't staff, and the numbers stopped making sense quickly, even at the cheaper tier.
Most identity systems, including the guest invite setup we started with, are really built around the idea of one organisation's staff logging into that organisation's tools. CIAM stands for Customer Identity and Access Management, and it's the different product category built for the opposite case: a large group of people outside your organisation, customers, members of the public, or in our case drivers, who need their own straightforward way to sign in, without being staff and without being treated as a guest on someone else's account.
Microsoft's version of this is called Entra External ID. Same underlying company, same relationship we already have, but a genuinely different product shape underneath, built for exactly the "lots of external people, not staff" situation we were in.
Here's the part that actually matters most, and it's the part I skipped past earlier: this is the specific thing that fixes the mess described at the start of this post. With a guest account, the second factor was never ours, it depended on whatever we'd chosen to trust, or on an account we had no visibility into at all, registered against whatever security info happened to be attached to it, a family member's phone, an unknown email, a number the driver had long since lost. With a CIAM account, that entire dependency disappears. The driver gets a real account inside a tenant we control, with an authentication method we register directly against it, a phone number we hold and can verify. If a driver loses access to that number, it's our tenant, our record, our problem to fix, not an investigation into somebody else's account we can't see inside. That's not a nice-to-have on top of a tidier login screen. It's the actual reason this redesign solves the problem it set out to solve.
Why this specific option won out comes down to that, plus a few things stacking on top. Driver accounts get created directly, no invitation to accept. Every driver sees the identical login screen and identical second factor, regardless of what personal email provider they happen to use. The free tier covers a genuinely large number of monthly active users at no licensing cost, that's the account itself, SMS delivery is a separate, metered add-on I'm still working out the real cost of, which fits a large, high-turnover, non-staff population far better than per-seat licensing ever would. It stays inside our existing Microsoft relationship rather than adding a vendor to explain to MOE. And, not unimportantly, it's the direction Microsoft is actively steering people, the older product this replaces, Azure AD B2C, is no longer available to new customers.
Quick reality check before anyone reads "Entra" and starts worrying: this doesn't put drivers anywhere near our real staff systems. It's a completely separate External ID tenant, its own space, not an extension of the production Entra tenant our staff accounts actually live in. Same landlord, different building is probably the easiest way to think about it. Sharing a vendor name doesn't mean it's touching anything in production.
And there's one more thing worth keeping straight, because it's an easy one to blur: this only handles authentication, proving a driver is who they say they are. It's got nothing to do with authorisation, deciding what that driver's actually allowed to do once they're in, that part still lives entirely in RADAR. Easy mix-up to make when one product's running the whole login screen, and it's the same one I had to watch for when I wrote up the kiosk security group a while back.
Testing It Myself Before I Ever Rang Our MSP
Production-grade identity tenants in our world require directory-level provisioning rights that sit with our MSP, not with the level of access I carry day to day. Which meant that before I could ask anyone for their time, I needed to be reasonably confident the idea actually held together. So I built a proof of concept in a personal Azure tenant, created under my own personal Microsoft account and funded out of my own pocket, entirely separate from Ritchies' Azure environment, billing, or infrastructure. No company data, no driver data, nothing that could go wrong for anyone but me. That was a deliberate choice, not an accident of convenience. It meant I could prove the mechanics actually worked before asking for any company resources, access, or MSP time, rather than the other way around, requesting access first and finding out afterwards whether the whole idea was sound.
I want to be upfront that this wasn't a smooth, tidy build. A handful of things went sideways in ways the documentation didn't warn me about, and they're the genuinely interesting part of this story.
Email one-time codes, where a code sent to email is the entire login with no password involved at all, was never something I seriously explored. A meaningful number of our drivers don't carry a smartphone comfortable for reading email on the move, but SMS reaches any mobile phone, so SMS was the plan from day one, not something I backed into after trying something else first.
What did shift partway through was whether SMS should be the primary login method or a second factor sitting on top of something else. My first instinct was passwordless entirely, email plus an SMS code and nothing else standing between a driver and their account. Reading further into Microsoft's own roadmap changed my mind. Microsoft has announced it's retiring Microsoft-provided SMS and voice as an authentication method, passkeys become the default from September 2026, and the SMS and voice option itself is retired outright from February 2027, unless an organisation configures its own third-party telecom provider through the Microsoft Security Store. That retirement doesn't currently extend to Entra External ID tenants, more on that later in this post, but it was enough to put me off building the entire login around SMS as the only thing standing between a driver and their account. So SMS moved to sit as the second factor, enforced by Conditional Access on top of a first factor, rather than being the whole login by itself.
Source for the retirement timeline: Microsoft's FAQ for the SMS and voice retirement, which also confirms Entra External ID isn't in scope of this particular announcement, a separate timeline is coming for CIAM specifically.
For that first factor, I didn't invent something new for drivers to remember. We already have a vendor app running on the same tablets, with its own login screen and its own short employee PIN, so I built the password around that same PIN rather than a byte-for-byte copy of it, close enough that a driver who already knows their vendor-app PIN can reconstruct this one without learning something new from scratch, but not an exact match. A plain four-digit PIN on its own was never on the table, Entra enforces a minimum length and character mix on External ID accounts, no exceptions, so a bare PIN gets rejected outright rather than discouraged.
Typing a phone number into a profile does nothing for MFA
There's a phone number field on a user's profile in Entra, and it looks exactly like the kind of place you'd register a number for SMS verification. It isn't. That field is just data, it's never consulted for MFA. The number that actually gets used for a second factor has to be registered through a completely separate system, the authentication methods API, as its own distinct object attached to the user. Two fields that look interchangeable and aren't, and nothing in the portal warns you which one you're looking at.
The "Create new user" route I found made the wrong kind of account
This is the one that genuinely surprised me. The portal's own "Create new user" screen creates a tenant-issued identity, something like a random ID at the tenant's onmicrosoft.com domain, not a local account keyed on a driver's real email address. The route I found at the time through the portal wasn't the account shape I actually needed, admin-created, no self-signup, a real email as the sign-in identity, so I dropped to the Microsoft Graph API directly instead. I hadn't expected to need Graph Explorer just to create a single test user. Microsoft's admin experience for this keeps evolving, so if you're reading this later, check the current portal before assuming Graph is your only option, it may not be by the time you're doing this yourself.
Here's the call I ended up making by hand in Graph Explorer, with the values you see here standing in for what I actually tested with:
{
"accountEnabled": true,
"displayName": "Test Driver",
"passwordProfile": {
"password": "<a-pin-that-meets-the-tenant-password-policy>",
"forceChangePasswordNextSignIn": false
},
"passwordPolicies": "DisablePasswordExpiration",
"identities": [
{
"signInType": "emailAddress",
"issuer": "ritchiesext.onmicrosoft.com",
"issuerAssignedId": "driver@example.com"
}
]
}
That returns a 201 with the new user object:
{
"@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users/$entity",
"id": "<guid>",
"businessPhones": [],
"displayName": "Test Driver",
"givenName": null,
"jobTitle": null,
"mail": null,
"mobilePhone": null,
"officeLocation": null,
"preferredLanguage": null,
"surname": null,
"userPrincipalName": "<guid>@ritchiesext.onmicrosoft.com"
}
That id from the response then goes into a second, separate call to register the driver's phone as an actual MFA method, not the profile field from Detour one:
{
"phoneNumber": "+64XXXXXXXXX",
"phoneType": "mobile"
}
{
"@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users('<guid>')/authentication/phoneMethods/$entity",
"id": "<guid>",
"phoneNumber": "+64XXXXXXXXX",
"phoneType": "mobile",
"smsSignInState": "notAllowedByPolicy"
}
smsSignInState: notAllowedByPolicy looks like a rejection the first time you see it, it isn't. It's a separate feature, passwordless sign-in using just a phone number instead of email and password, and our tenant doesn't have it turned on. It has nothing to do with SMS working as a second factor, which is the only thing this design actually relies on, and that keeps working regardless of what this field says.
Registering the phone method needs its own separate Graph permission consent, UserAuthenticationMethod.ReadWrite.All, distinct from the User.ReadWrite.All that creating the user needed. If the second call fails on a permissions error, that's almost certainly why, it isn't inherited from the first consent.
Getting the Second Factor to Actually Enforce
The login flow I ended up proposing is genuinely simple to describe, which made it all the more frustrating when the enforcement side of it quietly failed to work:
- Driver enters their email address and a PIN, acting as the password.
- Conditional Access requires a second factor, a one-time SMS code to a verified mobile number.
- Driver enters the code and is signed in.
Conditional Access, if you haven't run into the term, is Microsoft's name for a set of extra rules that can sit on top of a normal login, requiring things like a second factor, a specific device, or a fresh sign-in after a certain amount of time. It's the layer doing the actual enforcement work here, not the PIN.
The genuinely important distinction here, and the one I keep having to explain to people who assume otherwise: the email-and-PIN combination is deliberately designed for low friction, not as the thing carrying the real security weight on its own. SMS supplies the separately verified possession factor, sent to a mobile number we already hold and can verify through our existing HR and roster data. Microsoft documents telephony throttling on the SMS side, limiting how many messages can go out, specifically to prevent abuse. I haven't independently confirmed whether failed code entry attempts are rate-limited the same way password sign-ins are, that's a documented "Smart Lockout" concept for passwords specifically, and I don't want to claim it extends to OTP entry without having actually verified it.
The obvious question this raises is what happens when the SMS itself doesn't turn up, a driver in a coverage dead zone, a phone that's died mid-shift. I'm not going to pretend there's an elegant answer. There isn't. RADAR already gives ops the ability to edit a driver's data directly, so the fallback is a phone call: the driver tells ops what happened, ops enters it on their behalf. It's not a graceful self-service path, and it puts a human in the loop for exactly the moment a driver is least able to wait around. We're living with that trade-off rather than solving it in this design, and I'd rather say so plainly than pretend the second factor never fails.
Setting the actual policy up is where Detour three lives.
Security Defaults and Conditional Access can't both be on
Before I could even test the policy, Conditional Access refused to enable at all. The reason was Security Defaults, a baseline protection Microsoft turns on for new tenants, and it's mutually exclusive with Conditional Access, you can't run both. This was my own personal tenant, not something our MSP manages, so there was no one to check with. I made the call myself, comfortable replacing a generic default with something more specific, Conditional Access enforcing at least a second factor, rather than removing protection outright.
This tenant exists for exactly this purpose, and nothing else. If a different app down the line needs a different security posture, a parent-facing portal on RADAR, say, wanting something stronger than a driver login, I'd stand up a separate CIAM tenant for that too, rather than trying to make one tenant's policies serve two different risk profiles at once.
With Security Defaults sorted, the payoff finally arrived: a real sign-in attempt with the test account's email and PIN, a prompt to verify by SMS, a code arriving on an actual phone, typed in, accepted. Small moment, but it's the one that mattered. Everything before it was proving pieces in isolation. That was the first time the whole chain worked end to end, on an account created entirely through the Graph API, no invite, no self-signup, nobody clicking "accept" on anything.
Turning a Manual Process Into a Small Service
Everything in the Detour two section above, I did by hand, one driver at a time, through Graph Explorer. That's fine for a proof of concept and genuinely not fine as an ongoing process. What I actually built is a small Azure Function in Python, timer-triggered, that runs those same two Graph calls as a full CRUD sync against CIAM, driven by BambooHR, on its own schedule, every two hours.
My first instinct, and I genuinely can't believe I got this far down the road before catching it, was to bolt this onto the existing onboarding automation I wrote about in an earlier Hackathon Friday post, the one running on a DC server. I talked it through with an AI before committing to that, and the reasoning that came back was simple enough that I felt a bit silly for not seeing it myself: BambooHR and CIAM are both cloud services, talking to each other over the internet no matter where the calling code runs. Running that sync from a domain-joined server adds a hop, a dependency on that server being up and unloaded, and resource contention on a process that's already doing other things, for zero actual benefit. So the Function runs entirely in the cloud instead, alongside BambooHR and CIAM themselves, with no part of it touching our own network at all.
It uses its own app registration, deliberately separate from the interactive sign-in app registration drivers actually sign in through, so that a leak of one credential doesn't expose the other. That registration holds the same two application permissions I'd consented to manually, User.ReadWrite.All and UserAuthenticationMethod.ReadWrite.All, granted with admin consent since application-level permissions don't work on a normal user's say-so. The core logic checks for an existing user by email before creating anything, so running it again over a driver who already exists just skips them rather than creating a duplicate.
{
"email": "driver@example.com",
"pin": "<password-that-meets-tenant-policy>",
"phone_number": "+64XXXXXXXXX",
"display_name": "Jane Driver"
}
There's no separate backfill script for the existing driver list, and I didn't need one. The Function's own logic, create if missing, update if changed, is exactly what a backfill is. The first time it ever runs against the real driver population, every driver without a CIAM account yet gets one, the same as any other run, just with more rows to get through. Whatever needed sorting out before that first run, I did the same way as the rest of the proof of concept, standalone calls through the Graph Explorer site, not a separate tool.
I specifically didn't build a front-end for this either, no screen for ops to trigger an update on demand. For MVP one, the two-hourly cycle covers the overwhelming majority of cases on its own, and I didn't want to spend time building an interface for an edge case that's genuinely rare: someone starting and needing access inside that two-hour window. When that does come up, I run the Graph API by hand, the same calls documented earlier in this post, which is a perfectly fine way to handle something that happens this infrequently. Building a whole self-service tool for an edge case would have been solving a problem we don't actually have yet.
Note to future me, for whenever MVP two comes around: check whether BambooHR exposes a webhook I can subscribe to instead of polling every two hours. If it does, that closes the gap between "someone started and needs access now" and the next scheduled run, without needing a front-end at all. Worth ten minutes of research before assuming the polling interval is the ceiling.
Pointing the App at a Different Front Door
The last piece of the proof of concept, and the least glamorous, was working out how a production RADAR would actually authenticate against this new tenant instead of the old workforce one. RADAR runs on Azure Static Web Apps, and it currently uses the built-in azureActiveDirectory provider block. My first instinct was to just swap the issuer URL in that same block to point at the CIAM tenant. That causes a login loop. CIAM tenants run on the ciamlogin.com domain, and the built-in provider simply doesn't support them at all, no error message telling you why, just a redirect that never resolves.
The fix is a different config block entirely, customOpenIdConnectProviders, with its own client ID, its own client secret, and a well-known OpenID configuration URL pointing at the CIAM tenant. One prerequisite that'll waste your time if you hit it cold: custom authentication in Static Web Apps only works on the Standard plan, the free tier doesn't support it at all. RADAR was already on Standard, so it wasn't something I had to think about, but it's exactly the kind of thing future-me would otherwise waste twenty minutes rediscovering.
"auth": {
"identityProviders": {
"azureActiveDirectory": {
"registration": {
"openIdIssuer": "https://login.microsoftonline.com/<main-tenant-id>/v2.0",
"clientIdSettingName": "AD_AUTH_CLIENT_ID",
"clientSecretSettingName": "AD_AUTH_CLIENT_SECRET"
}
}
}
}
"auth": {
"rolesSource": "/api/GetRoles",
"identityProviders": {
"customOpenIdConnectProviders": {
"ciam": {
"registration": {
"clientIdSettingName": "CIAM_CLIENT_ID",
"clientCredential": {
"clientSecretSettingName": "CIAM_CLIENT_SECRET"
},
"openIdConnectConfiguration": {
"wellKnownOpenIdConfiguration": "https://ritchiesext.ciamlogin.com/<tenant-id>/v2.0/.well-known/openid-configuration"
}
}
}
}
}
}
Swapping the block isn't the whole change either. The login path moves, /.auth/login/aad becomes /.auth/login/ciam, since the provider key you choose determines the path, which means every hardcoded login link in RADAR and any responseOverrides referencing the old path need updating too. The redirect URI on the CIAM app registration has to match that new path exactly, callback and all. And when registering that redirect URI, it needs to be the Web platform type, not SPA, because SWA's own auth service is doing the code exchange server-side as a confidential client. Registering it as SPA can silently break the flow, another one of those failures that doesn't announce itself clearly.
When you register an app with an identity provider, you have to tell it what kind of app is going to be doing the logging in, because that changes how much the app can be trusted with. SPA stands for Single Page Application, think a React or Vue app running entirely in the driver's browser. Everything in that app's code is visible to anyone who opens their browser's dev tools, so it can never be trusted with a secret. Its login flow is built around that, no client secret involved, ever.
Web means a server is doing the work instead, somewhere the code isn't visible to the person using it, and that server can safely hold a real secret. Static Web Apps' authentication service is exactly that, a server sitting between the driver and CIAM, holding the client secret and doing the actual token exchange on RADAR's behalf. Register the redirect URI as SPA and you're telling CIAM "no secret is coming," which doesn't match what SWA is actually doing behind the scenes, register it as Web and the two sides agree on what's supposed to happen.
Don't assume existing role mappings carry over. CIAM issues different subject and identity values than the workforce tenant did. Authorisation needs to be explicitly re-tested after a migration like this, not assumed to keep working just because login succeeds. This turned out to be more than a theoretical warning, it's exactly what happened when I actually plumbed the whole thing end to end.
Signed In, But Still Locked Out
RADAR's authorisation runs through Static Web Apps' rolesSource mechanism, a small Python Azure Function called GetRoles that assigns a driver a "driver" role by matching their signed-in email against our driver database. Under the old B2B guest setup this had always just worked without me thinking about it twice, the value SWA passes in as userDetails reliably resolved to the user's email, and GetRoles matched against it with no special handling at all.
Once I pointed the app at CIAM, sign-in itself worked cleanly, SWA reported the test account as authenticated, no errors, no redirect loop. And the driver role never got assigned. Every request came back denied, on an account that had just proven it could sign in.
The first place to look is the app's /.auth/me endpoint, which returns the signed-in user's client principal. Here's roughly what came back:
{
"clientPrincipal": {
"identityProvider": "ciam",
"userId": "<guid>",
"userDetails": "Test Driver",
"userRoles": ["anonymous", "authenticated"]
}
}
userDetails wasn't the email at all. It was the display name I'd set on the test account in the CIAM tenant, "Test Driver". GetRoles was matching that string against a database full of email addresses, finding nothing, and handing back the default role. No error, no exception, just a quiet, correct-looking denial.
The default "access denied" experience is nearly useless for reporting this back. A driver hitting this sees, at best, a generic screen with no detail worth relaying to IT, and I'd already established earlier in this post that ops has neither the time nor the standard playbook to dig into something like this. So I added a small "technical details" panel inside RADAR itself, hidden behind a tap, that dumps the full client principal JSON the same way a browser's dev console would. A driver can't read it, but they can screenshot it and send it, which turns "it says I can't log in" into an actual, diagnosable bug report.
Digging past userDetails into the claims array on that same /.auth/me response is where the email actually was, the whole time:
"claims": [
{ "typ": "name", "val": "Test Driver" },
{ "typ": "preferred_username", "val": "driver@example.com" },
{ "typ": "sub", "val": "<guid>" }
]
The email was sitting under preferred_username the entire time, just not under the claim SWA had chosen to expose as userDetails.
The fix was almost embarrassingly simple once I knew what to look for: I changed the test account's display name, in the CIAM tenant, from "Test Driver" to the email address itself. userDetails then resolved to that display name value, which now happened to equal the email, and GetRoles matched it against the driver database without a single line of backend code touched. That mattered because the Python Function behind GetRoles is shared with other production systems, so any fix had to live entirely on the identity side, not the API side.
Microsoft documents a nameClaimType override directly on custom OpenID Connect providers, telling SWA which claim to treat as the identity for userDetails purposes, so this should genuinely be the cleaner fix rather than a documented-for-one-provider-type-only setting. What I haven't tested is whether pointing it at preferred_username actually produces the result I expect against this specific CIAM tenant. Until I've proved that end to end, production stays on the display-name workaround below.
This is a workaround, not a fix, and I want to be honest about that. It depends on every CIAM account's display name being set to the driver's email, correctly, forever, which is exactly the kind of quiet manual step that gets forgotten during a bulk import or a later account edit. The automated provisioning Function from earlier in this post currently sets displayName to the driver's actual name, which is the friendlier value to see in an admin screen and also, as it stands, the wrong one. I'll need to either change that Function to set display name to the email instead, ugly but reliable, or actually test the documented nameClaimType override against this tenant, since Microsoft says it should work here, I just haven't proved it does. For now, production runs on the workaround, documented as exactly that.
It also has a cost beyond fragility: it corrupts a real piece of identity data to fix an application-layer problem. If RADAR's UI ever wants to greet a driver by name rather than by email, that functionality is quietly broken for as long as this workaround stands, the system only knows her as "driver@example.com".
Matching on email at all is something Microsoft's own guidance would raise an eyebrow at. preferred_username and email are documented as mutable, and Microsoft's recommendation for a stable authorisation identifier is oid or sub, not either of those. I'm doing it anyway, deliberately, because email is already the business key our driver records are built around, and we've automated email changes from the same BambooHR sync described earlier in this post, so it isn't a static value we're gambling on staying still. That's a different justification than "email is a safe identity claim." It's "email is our existing key, and we've built the plumbing to keep it accurate," and those are two different claims that shouldn't get blurred together.
The general lesson, for anyone doing a similar B2B-to-CIAM move on Static Web Apps: don't trust userDetails at face value, and don't assume a documented config option is actually resolving the way you expect in your specific tenant until you've checked. Pull the full claims array on /.auth/me and verify what's actually in it before writing any authorisation logic against it.
Where This Sits Today
This is no longer a proposal sitting in a document. The decision record went to our MSP's cloud GM alongside a plain-English email covering the same ground, and I asked for 30 minutes to walk through it together rather than raise a formal ticket on the strength of a document nobody had pushed back on yet. That meeting happened, and rather than just being a sanity check, it turned into the session where we actually stood the production tenant up together, using their directory-level provisioning rights rather than mine. RADAR now authenticates drivers through Entra External ID in production. This is live, not a plan for later.
The thing that actually surprised me about that session: once the real tenant existed, turning the whole thing on took less than 30 minutes. Every step I'd stumbled through over days in my personal tenant, the sign-in identifier toggle, the login method, Conditional Access, the account creation, had already been figured out and written down. There was nothing left to discover, just steps to repeat. That's the actual payoff of doing the proof of concept properly and documenting it as I went, it doesn't just prove the idea works, it turns the real rollout into something closer to a checklist than a project.
Everything the proof of concept proved in isolation, in my own personal tenant, is what we carried across and stood up properly, with our MSP, in ours.
I'll be straight about one thing: I'm writing this section up from the fact that it's live, not from a full set of hard numbers on how each of the open questions from earlier in this post actually resolved. The real SMS cost at scale, and whether the session-persistence pattern from the kiosk project carried over cleanly to this identity product, are both things I'd rather report properly, with real figures, than guess at here just because the migration itself is done. Part of the reason a guess wouldn't even be a good one: there are too many variables I don't control to model this honestly from a whiteboard. Nothing stops a driver logging out and back in a few times just because they can, or leaving a session open longer than they need to. Real usage patterns, not assumptions about them, are the only thing that'll give me a number worth standing behind. If either of those turns up something worth writing about on its own, that's a follow-up post rather than a paragraph I'd rather not pad out with numbers I can't stand behind yet.
One thing I did check rather than leave as an assumption, and it's still worth watching even now this is live: Microsoft has announced the retirement of Microsoft-provided SMS and voice authentication, with passkeys becoming the default. I read the actual FAQ rather than the headline, and it's explicit that this particular change doesn't apply to External ID tenants, a separate, currently unannounced timeline is coming for CIAM specifically. Not a blocker today. Still a date on a calendar I need to keep glancing at, live system or not.
What This Taught Me
The part of this that surprised me most wasn't the technology. It was how much of the actual work turned out to be resisting the urge to reach for the answer that sounded most impressive, Auth0, rather than sitting with the boring question of what genuinely fits a large, non-technical, high-turnover population without adding a vendor we'd have to explain to the Ministry of Education. The unglamorous option won, and it won on the economics, not on being the newest thing.
I also don't think I'd have caught most of the detours in the testing section, the phone-number field that does nothing for MFA, the wrong account type from the admin "create user" screen, the Security Defaults conflict, the userDetails claim quietly pointing at a display name instead of an email, if I'd gone straight to production or straight to a conversation with our MSP without building the isolated proof of concept first. Testing something myself, somewhere it genuinely can't do any damage, before asking for anyone else's time, kept turning out to be worth the extra step, even when it felt like it was slowing me down in the moment. The theme running under all of it, if there is one, is that the hard part here was never cryptography. It was configuration and testing discipline, a field left on its default, a mutual exclusivity I hadn't read about yet, a claim exposed under the wrong name entirely. That's a meaningfully different risk profile than the one I walked in expecting.
What I'm genuinely still unsure about, and I'd rather say so than round it off into a tidier ending than it deserves, is whether every piece of this holds up as cleanly at real scale as it did in a personal tenant with one test driver in it. Going live answers the big question. It doesn't automatically answer the smaller ones underneath it, and I'd rather come back and write honestly about those once I actually know, than claim a cleaner win here than I've earned yet.
