Better Auth Integration
Status: LIVE for admin / business / FI / merchant personas. Related: DEMOZ_AUTH.md, ADR-029, ADR-013 (tenant isolation). Updated at each DemozAuth phase.
Better Auth (better-auth@^1.6.11) is the authority for email/password + TOTP logins by org-admins. Under the DemozAuth direction it is one adapter behind a port, not the platform — DemozAuth owns the employee (phone/OTP/PIN) flow, and Better Auth is reached through the ExternalSessionResolver seam.
Where it lives
| Concern | Path |
|---|---|
| Singleton factory (plugins, hooks, email flows) | apps/api/src/identity/auth/better-auth.factory.ts |
Mounted as a catch-all handler (owns /api/auth/*) | apps/api/src/identity/auth/auth.module.ts |
Session middleware (reads better-auth.session_token) | apps/api/src/identity/auth/session.middleware.ts |
| Rate limiting | apps/api/src/identity/auth/auth-rate-limit.ts |
| Admin MFA enforcement | apps/api/src/identity/auth/admin-mfa-enforcer.service.ts |
| Identity-provider seam (canonical app interface) | apps/api/src/identity/auth/better-auth.identity-provider.ts |
DemozAuth bridge (foreign session → Principal) | apps/api/src/identity/adapters/better-auth/better-auth-bridge.adapter.ts |
Better Auth mounts its own Express subtree at /api/auth/*, bypassing Nest routing — it must own that subtree. Everything else in the API is Nest.
Plugins enabled
| Plugin | Purpose |
|---|---|
organization | workspaces, members, invitations, roles (owner/admin/member). Several mutation endpoints are closed (invitations/role-changes/removals are server-driven, not client POSTs). |
admin | platform-admin endpoints (/api/auth/admin/*), gated by User.role === 'admin'. A platform admin needs both AdminProfile.checkType === 'PLATFORM' (our canonical guard) and User.role === 'admin'. |
twoFactor (TOTP) | MFA for admins; sign-ins for 2FA-enabled users return a twoFactorRedirect. |
phoneNumber | present, but employee phone auth is migrating to DemozAuth (Phase 3). |
magicLink | email link flows. |
Sessions are server-side rows (no stateless JWT); the browser holds the better-auth.session_token cookie. Org admins auto-activate a sole organization on sign-in; raw API callers must POST /api/auth/organization/set-active (with Origin) first — see CLAUDE.md "TenantContextMiddleware needs an active org".
How Better Auth fits the DemozAuth model
DemozAuth never imports Better Auth. It declares the ExternalSessionResolver port:
interface ExternalSessionResolver {
readonly id: string; // 'better-auth'
resolve(input: { headers }): Promise<Principal | null>;
}
BetterAuthBridgeAdapter implements it: it reads the Better Auth session from request headers and maps it to a DemozAuth Principal (identityId, email, workspaceId, mfaLevel, source: 'better-auth'). Downstream guards consume the Principal, not Better Auth types. Swapping the provider replaces only this adapter — no other code moves.
request ─► BetterAuthBridgeAdapter.resolve()
│ reads better-auth session
▼
Principal { identityId, workspaceId, mfaLevel, source:'better-auth' }
│
▼
DemozAuth guards / RBAC
Migration posture (per ADR-029)
- Phase 3 — DemozAuth's phone/OTP/PIN flow takes over employee auth behind a feature flag; Better Auth's
phoneNumberplugin is retired for employees. - Phase 4 — Better Auth is scoped to email/password + TOTP (+ OAuth) for admin/business/FI/merchant, consumed only via the bridge.
- Phase 5 — optional removal once DemozAuth reaches parity. A business decision, not a rewrite: because everything already flows through
ExternalSessionResolver, removal means deleting one adapter and its factory, not touching guards or domains.
What we deliberately did NOT copy from Better Auth
We learned from Better Auth's plugin + hook + schema-extension model (see core/plugin-registry.ts, core/hook-bus.ts) but did not fork or vendor it. DemozAuth's plugin descriptors are transport-agnostic; its hashing is split into Hasher (Argon2id) + Digest (SHA-256) rather than Better Auth's scrypt; and its session model is identity-first with explicit IDENTITY vs WORKSPACE kinds.
Replacement plan — DemozAuth over the EXISTING tables
Decision (2026-06-25): DemozAuth will reuse the current Better Auth tables (extend with columns only where needed — no
Demoz*tables) and grow to cover all personas, so Better Auth can eventually be removed with existing credentials and logins continuing to work (no forced reset / re-login). This supersedes the earlier "newDemoz*tables, employee-only" plan in ADR-029 Phase 3c.
Proven: credential continuity is achievable
Better Auth 1.6.14 hashes passwords with scrypt (@better-auth/utils/password, node:crypto), not bcrypt. Format: "{saltHex}:{keyHex}" where salt = 16 random bytes as a 32-char hex string used as the salt string (not decoded), key = scrypt(password.normalize('NFKC'), saltHex, dkLen=64, {N:16384, r:16, p:1}) as 128-char hex.
ScryptPasswordHasher (apps/api/src/identity/demoz-auth/adapters/scrypt-password-hasher.adapter.ts) reproduces this exactly. Cross-verified at runtime: our hasher verifies a Better-Auth-made hash AND Better Auth verifies a hash we make (wrong passwords reject both ways). So during migration both engines read/write the same Account.password column — no rehash, no reset.
Port → existing table mapping
| DemozAuth port | Existing table | Add (nullable, additive) |
|---|---|---|
IdentityStore → Identity | User (id, email→primaryEmail, phoneNumber→primaryPhone, banned/role→status) | — |
CredentialStore → Credential | Account (providerId='credential' → PASSWORD Account.password; a providerId='pin' row → employee PIN, Argon2id) | failedAttempts Int?, lockedUntil DateTime? (PIN lockout — BA tracks neither) |
SessionStore → Session | Session (activeOrganizationId→workspaceId) | kind String?, tokenHash String? (DemozAuth digest-at-rest; BA rows keep token, DemozAuth rows set tokenHash) |
OtpStore → Otp | Verification (BA phone-OTP already lands here) or keep EmployeeOtp | attempts Int?, consumedAt DateTime? if using Verification |
MembershipStore → Membership | Member (userId→identityId, organizationId→workspaceId, role + MemberRole.roleName[]→roles) | — |
Two Hashers, by credential type: Argon2id for PIN/OTP (new, no legacy), scrypt-compat for PASSWORD (reads BA hashes). RBAC (RbacRegistry) is seeded from the existing Role.permissions JSON + Member/MemberRole.
Coexistence & cutover (additive, reversible, flag-gated)
- Add the nullable columns above (one additive migration — no backfill, no drops). Better Auth keeps working unchanged.
- Wire DemozAuth Prisma adapters over these tables (Phase 3c). Flag stays OFF.
- Sessions are not migrated. While Better Auth still runs, its session cookies stay valid (honored by BA / the
ExternalSessionResolverbridge), so no one is logged out. New DemozAuth logins issue DemozAuth sessions into the same Session table (tokenHashset). - Frontends (admin/employer/fi/merchant-web) move off the
better-auth/reactclient to a DemozAuth client incrementally, per portal — the largest piece of "replacing" BA is these four clients + their middleware cookie checks, not the backend. - When all portals are cut over and parity holds, remove the Better Auth factory + plugins (one adapter + the Express mount). Tables and credentials remain.
The open decision before the migration is the session-coexistence model (additive tokenHash column + keep BA cookies during transition vs DemozAuth adopting BA's raw-token+signed-cookie model on the Session table).