Authentication
Better Auth 1.4 handles email OTP (via Resend) and Google OAuth (popup + BroadcastChannel).
flowchart TD
User["User"] --> Choice{Method}
Choice -->|Email| OTP["Email OTP via Resend"]
Choice -->|Google| OAuth["Google OAuth Popup"]
OTP --> BA["Better Auth"]
OAuth --> BA
BA --> Cookie["Secure HttpOnly Cookie"]
Cookie --> Hooks["hooks.server.ts"]
Hooks --> Locals["event.locals.session / user"]
Locals --> Guard["Route guards in +layout.server.ts"]
- Session stored as a secure,
HttpOnlycookie. hooks.server.tspopulatesevent.localson every request.- Protected routes (
/portal/*,/dashboard/*) checklocals.userin+layout.server.ts.
Two hosts, one auth handler
Section titled “Two hosts, one auth handler”Better Auth’s auth.handler is mounted on both the SvelteKit web app and apps/api:
| Host | Path | Used by |
|---|---|---|
apps/svelte-web | /api/auth/* | Web flow (form actions, page loads) |
apps/api | /api/auth/* | Mobile + (eventually) browser tRPC |
Both write to the same Postgres tables — sessions are interchangeable across hosts.
Two layers, no token:
SameSite=Laxsession cookie. Web (app.<domain>) and the API (api.<domain>) are the same registrable site, so the cookie is sent on legit web→api requests but withheld from cross-site forgeries — this alone defeats classic CSRF. Native apps build theCookieheader by hand and aren’t subject to it.- Origin allowlist (defense-in-depth).
apps/apirejects any state-changing request whoseOriginheader is present but not inCORS_ORIGINS. Enforced inside tRPC as theenforceOriginmiddleware (throwsTRPCError FORBIDDEN, so clients always get a valid tRPC envelope) and asbrowserOriginGuardon the image-write routes (/api/avatar,/api/agent-avatar,/api/property-images). Browsers always sendOriginand can’t forge it; native sends none → exempt. Rejections are captured as Sentry warnings (fingerprintorigin-rejected).
The previous double-submit csrf_token cookie / X-CSRF-Token scheme (and the /api/csrf endpoint + mobile token store) was retired — it duplicated the Origin/CORS checks and provided no protection for the no-Origin native clients.
Cross-origin posture
Section titled “Cross-origin posture”Env-driven via four variables:
COOKIE_DOMAIN— addsDomain=.<domain>+crossSubDomainCookiesso the (alwaysSameSite=Lax) cookie is shared acrossapp.<domain>/api.<domain>. Does not change SameSite.CORS_ORIGINS— browser allowlist onapps/api.PUBLIC_API_URL— baked into svelte-web’s browser bundle at build time via Vitedefine, mirrored into the CSPconnect-src.TRUSTED_PROXY_CIDRS— whichX-Forwarded-For/X-Real-IPheadersgetTrustedClientIpwill honor.
See Settings → Environment variables for the canonical list.
OTP rate limiting
Section titled “OTP rate limiting”sendVerificationOTP enforces a per-email rate limit (5 / 300s) via rateLimitOtpEmail. Both hosts share one Valkey bucket per normalized email — distributed attacks across web + apps/api hit a single budget per victim.
Per-IP auth throttling
Section titled “Per-IP auth throttling”Both hosts wrap /api/auth/* with a Valkey-backed per-IP limiter before the Better Auth handler:
| Path class | Limit | Bucket |
|---|---|---|
Sensitive (sign-in, sign-up, email-otp, password reset) | 10 / 60s | auth-ip-sensitive |
| Other auth paths | 30 / 60s | auth-ip |
apps/api applies this in Hono middleware; apps/svelte-web applies it in handleAuth. Same bucket names across hosts so distributed credential-stuffing hits one budget.
Trusted client IP (production)
Section titled “Trusted client IP (production)”getTrustedClientIp() in @repo/server-services decides which IP feeds the auth rate limiter. In production, when TRUSTED_PROXY_CIDRS is unset, forwarded headers are ignored and the socket peer is used (fail-closed — clients can’t spoof X-Forwarded-For). In dev, unset CIDRs preserve the prior behaviour (headers honored) — the Dokploy dev environment leaves it unset. In production, set TRUSTED_PROXY_CIDRS on both apps/api and apps/svelte-web to the edge proxy ranges (e.g. Cloudflare’s published IPs).
IP hijack detection (history)
Section titled “IP hijack detection (history)”Better Auth previously bound sessions to client IP. False-positive logouts under Cloudflare edge routing + Mexican residential CGNAT outweighed the speculative defense, so session IP-binding is now disabled (advanced.ipAddress.disableIpTracking: true).
Before disabling, an IPv6 client was silently logged out after every login on dev.<domain> because Better Auth’s getIp stores IPv6 with a /64 subnet mask while the comparison side read it un-masked. The fix mirrored the same /64 masking in hooks.server.ts. The check is now disabled entirely, but the inlined normalizeIp helper remains for any future re-enablement — keep its /64 semantics aligned with @better-auth/core/utils/ip if Better Auth ever changes its default.
The primary defenses (SameSite=Lax cookie attrs, the Origin allowlist, Sentry scrubbing) are intact.
Roles & onboarding
Section titled “Roles & onboarding”| Role | Description |
|---|---|
INDIVIDUAL | Default — can browse, favorite, list properties as homeowner |
AGENCY_ADMIN | All individual privileges + manage agency settings, members, billing |
ADMIN | Full platform administration |
Users don’t need an Agent role to list properties. AgentProfile is an optional extension that adds directory visibility, bio, service areas, and team membership. The /portal/become-agent flow handles onboarding (mounted as a global Dialog at the root layout — opened from three entry points).
Mobile
Section titled “Mobile”The native apps (apps/ios-user / apps/android/app-user + the agent apps) own the Cookie: header by hand — not Bearer tokens — storing it in the Keychain (iOS) / EncryptedSharedPreferences (Android) and capturing Set-Cookie rotations from every response. Google sign-in uses the native-SDK idToken flow — the app obtains an idToken locally (GoogleSignIn-iOS / Android Credential Manager) and posts it to POST /api/auth/sign-in/social (no browser/proxy; the idToken audience is the existing web client id, so the backend is unchanged). A 401 on any request triggers a global sign-out. Like every mobile client they send no Origin and are exempt from the allowlist. See Conventions → Mobile (Native).
Sentry scrubbing
Section titled “Sentry scrubbing”All four non-mobile Sentry inits (apps/api, svelte-web server, svelte-web client, apps/worker) install a beforeSend scrubber that redacts:
cookie/authorization/csrfheaders- any body fields matching
password|token|otp|secret
Without this, Sentry defaults capture the full Cookie header on every error. The native apps (sentry-cocoa / sentry-android) init manually with a header-scrubbing beforeSend and sendDefaultPii: false, keeping the session cookie / IP / user out of events.