BetterAuth — Authentication Layer¶
Centralized authentication for all Bricks apps (investor mobile, investor web, project-owner portal, admin backoffice).
Dual-instance architecture (May 2026)¶
Two BetterAuth instances are built from the same factory, sharing the same Postgres tables and secret but isolated by route path and cookie prefix. This allows a browser to hold both an admin backoffice session and an investor session simultaneously without cookie collisions.
clientAuth |
adminAuth |
|
|---|---|---|
| Purpose | All client apps (investor mobile/web + project-owner portal) | Admin backoffice only |
| Route base path | /api/auth/* |
/api/auth/admin/* |
| Cookie prefix | better-auth (default, kept for backward compat) |
bricks-admin |
| CORS / trusted origins | CORS_CLIENT_URLS + native schemes + Apple SSO |
CORS_ADMIN_CLIENT_URLS |
| OAuth providers | Google, Facebook, LinkedIn, Apple | Google only |
| Email/password | Enabled | Disabled (Google OAuth only) |
| Plugins | expo(), twoFactor, emailOTP, admin(), lastLoginMethod() |
admin(), lastLoginMethod() |
| Session guard | JwtAuthGuard (Better Auth cookie session) |
AdminAuthGuard |
Both instances share the same better_auth_* tables and pg.Pool. Isolation is at the HTTP cookie layer only.
src/lib/better-auth/
├── better-auth.ts Entry point — exports clientAuth + adminAuth
├── better-auth.factory.ts Parameterized factory (cookiePrefix, basePath, plugins à la carte)
├── better-auth.constants.ts Cookie prefixes, impersonation TTL
├── better-auth.utils.ts Header conversion, cookie relay helpers
├── better-auth.repository.ts DB queries (legacy password rehash, customer linkage)
├── better-auth-fastify.module.ts NestJS module that mounts both instances on Fastify
├── client-auth-impersonation.service.ts Admin→client impersonation bridge
├── generate-apple-client-secret.ts Apple SSO JWT client secret (ES256, 6-month TTL)
├── hooks/
│ └── oauth-error-redirect.hook.ts Rewrites OAuth error redirects to correct client
├── should-use-cross-origin-cookies.ts Cross-origin cookie detection logic
Auth guards¶
| Guard | Protects | Module |
|---|---|---|
JwtAuthGuard + UserRoleGuard |
Investor endpoints | src/__new/modules/customer-auth/ |
AdminAuthGuard |
Admin endpoints | src/__new/modules/admin-auth/ |
Admin auth (BetterAuth-only, May 2026)¶
Admin authentication was fully migrated to BetterAuth cookie sessions in May 2026 (#5057). The legacy admins table was dropped.
How it works:
- Admins are rows in better_auth_user with role = 'admin'
- AdminAuthGuard calls auth.api.getSession({ headers }) and checks user.role === 'admin'
- No JWT fallback — exclusively cookie-based
- The admin_legacy_id_mapping table preserves the old admins.id → better_auth_user.id mapping for cross-referencing in external dashboards
Customer auth (Better Auth sessions)¶
Investor REST endpoints require a valid Better Auth session cookie on clientAuth (/api/auth/*). Native apps persist the cookie via @better-auth/expo; web clients use credentials: 'include'.
Social providers¶
| Provider | Notes |
|---|---|
| Standard OAuth2 | |
| Standard OAuth2 | |
| Standard OAuth2 | |
| Apple | Uses form_post response mode; clientSecret is a dynamically-generated ES256 JWT (6-month TTL). appleid.apple.com must be in trustedOrigins because Apple POST-submits back to the callback |
OAuth error handling¶
Two hooks intercept failed OAuth flows:
-
Before hook (
createOAuthOriginCookieHook): On/sign-in/social, captures thecallbackURLorigin into abricks_oauth_origincookie (validated against trusted origins). Purpose: know where to redirect on error. -
After hook (
createOAuthErrorRedirectHook): Intercepts BetterAuth's default error redirect (/error?error=...or/callback/:provider?error=...) and rewrites: - Web: redirects to
${cookieOrigin}/login?error=social-auth - Native: cookie is absent (Safari/ASWebAuthenticationSession jar isolation) — redirects to
${scheme}://login?error=social-authwhere scheme isbricks(prod) orbricksdev(other envs)
State mismatch mitigation¶
skipStateCookieCheck: true disables BetterAuth's state-cookie equality check. CSRF protection is maintained via the DB verification row (single-use, 10-min TTL, PKCE codeVerifier bound). This avoids false state_security_mismatch failures from cookie propagation issues on native.
Admin impersonation (May 2026)¶
"Login as customer" from the backoffice. The admin session (bricks-admin.* cookie) triggers impersonation on clientAuth, producing a separate client session (better-auth.* cookie) for the target customer.
Flow:
1. Admin hits GET /administration/customers/:id/admin-login (protected by AdminAuthGuard)
2. ClientAuthImpersonationService relays the admin session cookie from bricks-admin.* → better-auth.* prefix (same signed token, same DB row)
3. Calls clientAuth.api.impersonateUser({ userId: target.betterAuthUserId })
4. Response forwards client-prefix Set-Cookie headers + { url: frontUrl } for redirect
5. Impersonation session expires after 10 minutes (ADMIN_IMPERSONATE_SESSION_DURATION_SEC)
The admin's backoffice session (bricks-admin.*) remains untouched — both sessions coexist in the same browser.
Plugins¶
| Plugin | Purpose | Instance |
|---|---|---|
expo() |
Rewrites expo-origin header to origin so native requests pass CSRF validation |
clientAuth only |
admin() |
Adds role field to users, enables admin-specific session checks and impersonateUser |
Both |
lastLoginMethod() |
Tracks last auth method (email / OAuth provider) — cookie + better_auth_user.lastLoginMethod |
Both |
twoFactor() |
TOTP-based 2FA with OTP delivered via Customer.io transactional email | clientAuth only |
emailOTP() |
6-digit email verification code (300s TTL), delivered via Customer.io | clientAuth only |
lastLoginMethod uses storeInDatabase: true (column on better_auth_user, applied by the Better Auth migrate CI step — and by pnpm better-auth:check-and-migrate --apply in the integration global-setup, since that harness is otherwise Flyway-only).
Pre-login badge (investor app): the plugin cookie is API-host-scoped (not readable from the front). After a successful login the client writes the known method to local storage (useLogin / native social / 2FA). Web social stashes the provider in sessionStorage before redirect and SessionGateProvider applies it only once a session exists — pending is cleared on login screen mount, email/2FA start, and OAuth error so an abandoned social click cannot overwrite a later email login.
Database tables¶
| Table | Purpose |
|---|---|
better_auth_user |
Users (investors + admins). Roles: null (investor), 'admin'. Optional lastLoginMethod (email / OAuth provider id) |
better_auth_session |
Active sessions (cookie-based) |
better_auth_account |
Credential + social provider links per user |
better_auth_verification |
OAuth state, email OTP, and 2FA verification tokens |
better_auth_two_factor |
2FA configuration per user |
admin_legacy_id_mapping |
Reference table: old admins.id → better_auth_user.id |
Database hooks¶
On session creation (databaseHooks.session.create.after):
1. findOrCreateCustomerForBetterAuthUser — ensures a customers row exists for the user (first-login scenario). Emits account_created event to Customer.io if new investor.
2. ProjectFinancingRequestOwnerService.findOrCreate — ensures a project-financing-request owner record exists (for the project-owner portal).
3. updateLastLoginAtByBetterAuthUserId — updates customers.lastLoginAt.
Password handling¶
- Current: Argon2id (timeCost=3, memoryCost=64MB, parallelism=4)
- Legacy: HMAC SHA-256. On successful verify, automatically rehashes to Argon2id (transparent migration)
- Min password length:
MIN_PASSWORD_LENGTH_API(8) on the API validator until all clients enforce 12 chars; mobile/web clients validate withMIN_PASSWORD_LENGTH(12) viapasswordValidator
Password recovery & change (clientAuth, May 2026)¶
Investor mobile and web call Better Auth directly — no legacy POST /customers/forgot-password for new flows.
| Client method | Route (under /api/auth/*) |
Used by |
|---|---|---|
requestPasswordReset |
forgot-password flow (email with reset link) | useRequestPasswordRecovery |
resetPassword |
reset with token from email deep link | useResetPassword |
changePassword |
authenticated password update | useChangePassword |
changePassword accepts revokeOtherSessions: boolean. When true, other better_auth_session rows for the user are invalidated; the calling session remains valid.
Legacy Redis MFA (POST /auth/mfa/*) still exists for some flows (withdraw, email update). Login 2FA uses Better Auth twoFactor plugin — see mobile AUTH_FLOWS.md.
Session listing & revocation¶
| Client method | Purpose |
|---|---|
listSessions |
Returns active sessions (user agent, createdAt, token id) for the Security screen |
revokeSession |
Ends a single session by token; current session cannot be revoked from the UI |
Sessions are rows in better_auth_session, scoped to clientAuth cookie prefix.
TTL:
- clientAuth — optional BETTER_AUTH_SESSION_CLIENT_EXPIRES_IN_SECONDS (e.g. 2592000 = 30 days); omit to keep Better Auth’s 7-day default
- adminAuth — Better Auth default (7 days); shorter on purpose for the critical backoffice surface
Better Auth default updateAge (1 day) still applies: when a session is used after ≥1 day, expiresAt is pushed to now + expiresIn. Each device/login has its own row and clock (desktop vs mobile are independent).
Cookie strategy¶
Cookie names are determined by the instance's cookiePrefix:
| Instance | Session cookie (HTTP) | Session cookie (HTTPS) |
|---|---|---|
clientAuth |
better-auth.session_token |
__Secure-better-auth.session_token |
adminAuth |
bricks-admin.session_token |
__Secure-bricks-admin.session_token |
| Condition | sameSite |
secure |
|---|---|---|
| Cross-origin (API domain ≠ front domain) | none |
true |
| Same-origin (localhost dev) | lax |
false |
Detection via shouldUseCrossOriginCookies(corsClientUrls, baseURL).
Native apps: @better-auth/expo handles cookie persistence in expo-secure-store. Known pitfall: iOS keychain throws KeyChainException when device is locked — the mobile app wraps storage access in a try/catch fallback (#5291).
Trusted origins¶
| Instance | Env var | Extra entries |
|---|---|---|
clientAuth |
CORS_CLIENT_URLS |
bricks://, bricksdev://, https://appleid.apple.com |
adminAuth |
CORS_ADMIN_CLIENT_URLS |
none (web backoffice only) |
Same comma-separated format for both vars. If the list contains * (local dev only), all HTTP/HTTPS origins are trusted for that instance. * is rejected in staging/production.
Guard → instance mapping: AdminAuthGuard must use adminAuth, investor/customer guards must use clientAuth. Mixing them breaks session lookup because the cookie prefix won't match.
IP address resolution¶
Uses Cloudflare's cf-connecting-ip header (not spoofable) with x-forwarded-for as fallback.
Troubleshooting¶
| Symptom | Likely cause |
|---|---|
state_security_mismatch on social login |
Double-tap triggering concurrent OAuth flows (mitigated by 2s throttle on front) |
Apple SSO → UNKNOWN error |
Expired/invalid Apple client secret (regenerated from private key, 6-month TTL) |
INVALID_ORIGIN on Apple callback |
appleid.apple.com not in trustedOrigins |
| 401 on admin endpoints | Session cookie not sent (withCredentials: true missing) or session expired |
| Admin login opens front but user is logged out | Set-Cookie from GET /administration/customers/:id/admin-login not stored — check CORS credentials + API cookie domain |
| iOS app crash on locked device | expo-secure-store keychain access — caught by safe storage wrapper |
INVALID_CALLBACK_URL on Google |
callbackURL contains encoded characters; fixed by proper URL encoding on front |