Skip to content

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, HttpOnly cookie.
  • hooks.server.ts populates event.locals on every request.
  • Protected routes (/portal/*, /dashboard/*) check locals.user in +layout.server.ts.

Better Auth’s auth.handler is mounted on both the SvelteKit web app and apps/api:

HostPathUsed 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:

  1. SameSite=Lax session 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 the Cookie header by hand and aren’t subject to it.
  2. Origin allowlist (defense-in-depth). apps/api rejects any state-changing request whose Origin header is present but not in CORS_ORIGINS. Enforced inside tRPC as the enforceOrigin middleware (throws TRPCError FORBIDDEN, so clients always get a valid tRPC envelope) and as browserOriginGuard on the image-write routes (/api/avatar, /api/agent-avatar, /api/property-images). Browsers always send Origin and can’t forge it; native sends none → exempt. Rejections are captured as Sentry warnings (fingerprint origin-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.

Env-driven via four variables:

  • COOKIE_DOMAIN — adds Domain=.<domain> + crossSubDomainCookies so the (always SameSite=Lax) cookie is shared across app.<domain> / api.<domain>. Does not change SameSite.
  • CORS_ORIGINS — browser allowlist on apps/api.
  • PUBLIC_API_URL — baked into svelte-web’s browser bundle at build time via Vite define, mirrored into the CSP connect-src.
  • TRUSTED_PROXY_CIDRS — which X-Forwarded-For / X-Real-IP headers getTrustedClientIp will honor.

See Settings → Environment variables for the canonical list.

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.

Both hosts wrap /api/auth/* with a Valkey-backed per-IP limiter before the Better Auth handler:

Path classLimitBucket
Sensitive (sign-in, sign-up, email-otp, password reset)10 / 60sauth-ip-sensitive
Other auth paths30 / 60sauth-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.

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).

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.

RoleDescription
INDIVIDUALDefault — can browse, favorite, list properties as homeowner
AGENCY_ADMINAll individual privileges + manage agency settings, members, billing
ADMINFull 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).

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).

All four non-mobile Sentry inits (apps/api, svelte-web server, svelte-web client, apps/worker) install a beforeSend scrubber that redacts:

  • cookie / authorization / csrf headers
  • 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.