Authentication
HASHPASS uses a provider-agnostic auth layer in code. Google sign-in has one canonical path for every tenant: Better Auth first, Supabase as a last-resort fallback. The Directus OAuth bridge described lower in this doc still exists in the tree but is not reachable from the Google button anymore — see AUTH_FLOW.md § Do we still need Directus?. Sign in with Apple is enabled for web/PWA and iOS when its protected provider credentials are available; Android keeps the Apple button hidden. See SIGN_IN_WITH_APPLE.md for the tested runtime flow and APPLE_SIGN_IN.md for portal credential operations.
What Happens In Production
- The app calls
useAuth.signInWithOAuth('google'). - Web: always tries Better Auth first —
signIn.social({ provider: 'google', ... })against<apiBase>/api/auth, redirecting to Google withredirect_uri=<apiBase>/api/auth/callback/google. Better Auth's own server exchanges the code and sets a session cookie, then redirects to/auth/callback. Only if this itself errors does the code fall back tosupabase.auth.signInWithOAuth(). - Native: uses the Google Sign-In SDK account picker, exchanges the ID token with
BetterAuthProvider.signInWithIdToken('google', idToken)first, then falls back tosupabase.auth.signInWithIdToken()only if Better Auth rejects the token or returns no session. - The web callback route (
/auth/callback) checks for a live Better Auth session first (via theauth_signin_method=google_oauthmarker set before the redirect), then falls back to the tenant's resolved provider (authService) only if that marker isn't present — see AUTH_FLOW.md for the full sequence. - The Directus bridge (
/api/auth/oauth/login→ Directus →/api/auth/oauth/callback) is unchanged in code but is not called by the Google button in any current flow.
Why This Design
- A single Better Auth backend for Google avoids two divergent user identities (Better Auth's
ba_usersvs Supabase'sauth.users) depending on which tenant/host resolved — that was happening under the brief Supabase-first design earlier the same day. - Better Auth's account picker shows your own domain (
api.hashpass.tech) instead of a raw Supabase project ref, which also fixes a real UX/trust concern on the consent screen. - Supabase remains the active session owner for passwordless email/OTP auth (unrelated to this change) and as the Google fallback if Better Auth itself is misconfigured for a given environment.
- Directus never reliably held browser cookies across the frontend/Directus origin split in production, which is why even its own bridge always ran through the API domain rather than linking the frontend to Directus directly — this is now largely moot since nothing calls it for Google anymore.
Required Environment
Better Auth Google path (default for every tenant on web):
BETTER_AUTH_SECRET=<...>
BETTER_AUTH_URL=https://api.hashpass.tech/api/auth
EXPO_PUBLIC_BETTER_AUTH_URL=https://api.hashpass.tech/api/auth
BETTER_AUTH_DATABASE_URL=<...> # or DATABASE_URL / DATABASE_URL_PROD
BETTER_AUTH_GOOGLE_CLIENT_ID=<...> # or GOOGLE_CLIENT_ID
BETTER_AUTH_GOOGLE_CLIENT_SECRET=<...> # or GOOGLE_CLIENT_SECRET
Google Cloud Console must have <apiBase>/api/auth/callback/google registered as an authorized redirect URI for every environment (https://api.hashpass.tech/... and http://localhost:8081/... are separate entries on the same OAuth Client).
Supabase fallback path (only reached after Better Auth fails):
EXPO_PUBLIC_SUPABASE_URL=<SUPABASE_URL>
EXPO_PUBLIC_SUPABASE_ANON_KEY=<SUPABASE_ANON_KEY>
Supabase's own Google provider must be enabled in that project's Dashboard, and each frontend /auth/callback URL must be in its Allowed Redirect URLs, for the fallback to actually work when reached.
See AUTH_FLOW.md for the full requirements list, the schema-migration steps, and troubleshooting for each of the three infra bugs found and fixed on 2026-07-08 (Metro bundler blocking Better Auth's own zod dependency, allowedHosts not matching host:port, and Better Auth's database tables never having been migrated on a given environment).
OAuth account identity integrity
The Better Auth account table must have one row per (providerId, accountId)
pair. Older adapter versions could create duplicate rows for the same Google
identity. Better Auth then rejects the callback with Multiple accounts match the same accountId, before it can issue the session cookie, and the browser
returns to the auth screen without a session.
The registered better-auth-account-integrity migration removes duplicates
only when every duplicate belongs to the same Better Auth user (keeping the
most recently updated token row), refuses ambiguous cross-user duplicates, and
adds a unique index for future writes. Run it against the database named by the
API runtime's BETTER_AUTH_DATABASE_URL; do not use a shared core database URL
just because the host is the same. Verify the runtime database after a deploy:
SELECT "providerId", "accountId", count(*)
FROM public.account
GROUP BY "providerId", "accountId"
HAVING count(*) > 1;
This query must return no rows. A successful callback should then be followed
by GET /api/auth/get-session returning a non-null session. CSP warnings from
accounts.google.com or browser extensions are unrelated to the HashPass API
unless the API callback is absent from the production Lambda logs.
Related Code
apps/mobile-app/hooks/useAuth.ts—signInWithOAuth('google')andhandleOAuthCallback(Better-Auth-first routing)packages/auth/src/providers/better-auth.ts—BetterAuthProviderapps/mobile-app/lib/server/better-auth.ts— server-side Better Auth configapps/mobile-app/app/(shared)/auth/callback.tsxapps/mobile-app/lib/supabase.ts— fallback path onlyapps/mobile-app/app/api/auth/oauth/login+api.ts/callback+api.ts— Directus bridge (not reachable from the Google button anymore)
Notes
- Better Auth is the expected browser Google path for every tenant now, not just BSL events.
- The browser no longer depends on a Directus session cookie to finish Google sign-in — it never did reliably, and now doesn't even try to for Google.
- Historical Directus-first guides should be treated as reference material only.