Verified Azure OAuth on Better-Auth — Design
Date: 2026-07-05 Author: Ali (with Claude) Status: Draft — pending review1. Problem — nOAuth account takeover
Azure (Microsoft Entra) OAuth was hidden from both login pages because we trust theemail claim Microsoft sends without verifying it is actually owned by the
signer. A user in any Microsoft tenant can set their account’s email to
[email protected]. On login we match/create an OpenCX user by that email, so
the attacker lands in the victim’s account. This is the documented nOAuth
pattern. Our app is registered as multi-tenant + personal accounts
(“All Microsoft account users”), which is exactly the exposed configuration.
Hiding the buttons (commit 8c51c1f661, 2026-06-29) was cosmetic — the providers
are still live server-side and the sign-in endpoints are directly reachable.
2. Scope
- Fix Better-Auth only. Implement verified Azure login on the Better-Auth
stack (
backend/src/platform-auth/) and re-enable the Microsoft button on the Better-Auth login page (/auth-2). - Do NOT touch NextAuth. NextAuth’s
/marhabapath is the truly vulnerable one (pure email find-or-create, zero verification), but NextAuth is being retired. A separate future session will delete everything NextAuth-related, which removes that path entirely. Until then its Azure button stays hidden and we do not re-enable it. - Non-goal: hardening
/backend/user/marhabaor the NextAuthsignIncallback.
3. Azure prerequisites (app registration)
Code alone cannot make Azure emit the verification signal — these are set on the Entra app registration and are a hard prerequisite. The code fails closed without them (no verification claim ⇒ login rejected), so the ordering is safe. Dev app —open.cx, client id d08b3b44-d4e1-4319-b95c-3f4afae48599, object id
d5310670-24a6-4125-b51a-545b46c1b7ac, tenant 2c87cf71-... — DONE (2026-07-05):
- ✅ Optional claim
emailadded to the ID token. - ✅ Optional claim
xms_edovadded to the ID token (“email domain owner verified”). - ✅
authenticationBehaviors.removeUnverifiedEmailClaim = trueset viaPATCH https://graph.microsoft.com/beta/applications/{objectId}with body{"authenticationBehaviors": {"removeUnverifiedEmailClaim": true}}. This strips the email claim entirely from tokens whose domain owner is unverified. Must be set explicitly because the safe default exempts multi-tenant apps with prior unverified sign-in activity.
AZURE_AD_CLIENT_ID (from deploy secrets, not the repo). Run the identical three
steps against the prod registration. Verify with
GET .../applications/{objectId}?$select=authenticationBehaviors.
4. Security model
Trust the Azureemail claim only when the token proves the email is
verified. Defense in depth, four independent layers — any one of which blocks the
takeover:
- Source (Entra):
removeUnverifiedEmailClaim: true⇒ unverified-domain logins arrive with no email ⇒ rejected atcallback.mjs:129(email_not_found) before any DB lookup. - Claims gate (our code):
mapProfileToUsercomputes a strictemailVerifiedfrom the claims and, when not verified, returnsemail: null+emailVerified: false— so even if Entra config regressed, an unverified login still has no usable email and is not trusted. - Linking gate (Better-Auth, existing):
microsoftis never a trusted provider, so linking a provider login to an existing user requiresuserInfo.emailVerified === true(link-account.mjs:22). An unverified login cannot adopt an existing account. - Creation gate (our code): the
user.create.beforehook rejects any user creation withemailVerified !== true, closing new-account email squatting.
disableIdTokenSignIn: true removes the client-submitted-ID-token sign-in
surface entirely (we only use the redirect code flow).
Why not throw from mapProfileToUser: the getUserInfo call at
callback.mjs:80 is not wrapped in try/catch, so a thrown error surfaces as a
generic unhandled failure, not a clean coded redirect. Security therefore rests on
the data-flow gates above (proven by reading better-auth 1.6.14 source), not on
exceptions. mapProfileToUser returns values; it does not throw.
Why claim-decoding without signature verification is safe here: in the
authorization-code flow, getUserInfo decodes the ID token that better-auth
fetched directly from Microsoft’s token endpoint over TLS. Provenance is the token
endpoint, not the browser, so the claims are authentic. (Signature verification —
approach “B” — would be redundant for this flow and only adds upgrade drift. The
one place a client supplies a raw token, ID-token sign-in, is disabled outright.)
5. Changes
5.1 New — backend/src/platform-auth/microsoft-claims.utils.ts
MicrosoftClaimsUtils (static-method class, per repo convention). Pure, fully
typed against MicrosoftEntraIDProfile from @better-auth/core — no any, no
as.
isEmailVerified(claims): boolean— strict:claims.xms_edov === true || claims.email_verified === true || (email != null && (verified_primary_email or verified_secondary_email contains email, case-insensitive)). Only booleantruecounts — a string"true"must NOT verify (Zod-typed boolean; defensive test below).mapProfileToUser(profile) => { email: string | null; emailVerified: boolean }— verified ⇒{ email: profile.email ?? null, emailVerified: true }; not verified ⇒{ email: null, emailVerified: false }. Also logs a structuredconsole.error('[better-auth] microsoft email not verified', { tid, hasEdov, hasEmailVerified })on rejection for HyperDX (no PII beyond tenant id; never useerroras an attribute key — use_e).
mapProfileToUser?: (profile) => { email?: string | null; emailVerified?: boolean; ... }, so the return overrides the provider’s
default emailVerified computation (spread last in getUserInfo).
5.2 backend/src/platform-auth/better-auth.service.ts
socialProviders.microsoftgains:mapProfileToUser: MicrosoftClaimsUtils.mapProfileToUserdisableIdTokenSignIn: true
- Add
account: { accountLinking: { requireLocalEmailVerified: false } }.- Rationale: legacy users migrated from NextAuth have
users.email_verified = false. With the default (true), Better-Auth blocks linking any provider (including Google) to those accounts. Settingfalselets a provider-verified login link to a legacy account; Better-Auth then auto-sets localemail_verified = trueon link (link-account.mjs:48/64), so the column self-heals. Safe because takeover is still blocked by the provider-sideemailVerifiedrequirement (microsoft not trusted). microsoftis not added totrustedProviders— deliberately.
- Rationale: legacy users migrated from NextAuth have
databaseHooks.user.create.before— add anemailVerified !== truerejection (returnsfalse) alongside the existing personal-domain check. Password signup is disabled and Google is always verified, so this only bites unverified OAuth.
5.3 dashboard/apps/dashboard/app/auth-2/BetterAuthLoginPanel.tsx
- Restore the “Continue with Microsoft” button (the
startSocial('microsoft', …)handler already supports it; only the button JSX was removed). - On callback redirect back with an
?error=param, show a friendly message. Better-auth can surfaceemail_not_found/account_not_linkedon the reject paths — copy: “We couldn’t verify your Microsoft email. Ask your organization’s admin, or sign in with Google / your work email.” Keyed off presence of the error param (not a custom code, per §4). - NextAuth
/authpage untouched.
6. Rejection matrix (data flow)
| Actor | Claims | Outcome | Gate |
|---|---|---|---|
| Attacker, own tenant, email=victim@x | domain unverified ⇒ no email (Entra) | rejected, no DB write | §4.1 email_not_found |
| Attacker (if Entra regressed) | email present, xms_edov=false, email_verified absent | email:null, not trusted ⇒ rejected | §4.2 + §4.3/§4.4 |
| Legit existing user | verified (xms_edov=true) | links auth_accounts once, thereafter matches by (sub, provider); local email_verified self-heals to true | §4.3 |
| Legit new business user | verified + business domain | account created | passes §4.4 + existing domain gate |
| New personal MSA | no xms_edov (Entra-only claim) | rejected | §4.2 + business-domain gate |
Claim xms_edov: "true" (string) | not boolean true | not verified ⇒ rejected | §4.2 (defensive) |
7. Testing (extensive + defensive — mandatory closing step)
Real integrations, one scenario per file where it maps to a distinct path. Unit —MicrosoftClaimsUtils (microsoft-claims.utils.spec.ts): every branch —
xms_edov true / false / absent; email_verified true / false / absent; email in
primary list; email in secondary list; case-insensitive email match; email absent;
empty verified lists; junk/extra claims pass through; string "true" does NOT
verify (defensive); verified path returns the email, unverified returns
email: null.
Integration — the provider seam (microsoft-claims.provider-integration.spec.ts):
construct the real microsoft(...) provider from @better-auth/core with our
options, feed crafted ID tokens (a signed/opaque JWT with controlled claims), and
assert our mapProfileToUser return overrides the provider’s default
emailVerified, and that an unverified token yields email:null /
emailVerified:false. Pins the spread-order assumption so a better-auth bump that
changes it fails loudly.
Config regression (better-auth-config.security.spec.ts): assert microsoft
is not in resolved trustedProviders; requireLocalEmailVerified === false is the
only linking relaxation; disableIdTokenSignIn === true. A future config edit that
reopens the hole fails here.
Create-gate (better-auth-user-create-gate.spec.ts): user creation with
emailVerified !== true rejected; true + business domain passes; existing
personal-domain rejections still hold (no regression).
Automated e2e — the composed takeover-blocked outcome
(microsoft-oauth-callback.e2e.spec.ts): drives the REAL prod config
(BetterAuthService) through the actual sign-in/social → callback handshake,
offline (test creds injected via env before the module builds; the Microsoft
token endpoint + Graph photo stubbed at the fetch boundary — everything inside
better-auth runs for real). Asserts: (1) an unverified email for an EXISTING
victim ⇒ 302 error, no session cookie, victim’s auth_accounts/auth_sessions
untouched; (2) string "true" xms_edov ⇒ same rejection; (3) a verified login for
a legacy (email_verified=false) user ⇒ links + session + self-heals
email_verified; (4) a verified new business email ⇒ account created + linked +
session. This is the proof the fix blocks the takeover, not just that our helper
returns the right value.
Manual e2e (with Ali, after Azure verified): real Microsoft login on dev to
confirm live claims arrive — happy path + attack simulation from a real test
tenant. Documented because it needs a live Microsoft tenant we can’t automate.
8. Rollout order
- Land + deploy backend code — safe immediately; fails closed even before Entra is configured on prod.
- Configure the prod app registration (§3 TODO). Verify via Graph GET.
- e2e verify on dev/staging (happy path + attack sim).
- Ship the Microsoft button on
/auth-2. - (Future session, tracked separately) delete NextAuth entirely — until then its Azure path stays hidden.
9. Open questions / follow-ups
- Confirm prod
AZURE_AD_CLIENT_IDand run §3 against it before step 4. requireLocalEmailVerified: falseis global to Better-Auth account linking (not microsoft-scoped — better-auth has no per-provider setting). Google linking to legacy accounts benefits identically; confirm that is acceptable (it is — Google is verified-only too).