Authentication Flow
Passes / admin roles missing for a Better Auth user? See USER_REGISTRY.md § Path 4 — Better Auth → Supabase bridge (v1.8.273). Every Better Auth sign-in now also bridges to a real Supabase
auth.usersaccount, which is what BSL pass provisioning and admin-role FKs actually key off of — this flow doc covers how the user signs in, not how their identity gets bridged afterward.
Dashboard stability: the Better Auth bootstrap and
useAuth()update contract is a critical runtime boundary. See AUTH_BOOTSTRAP_STABILITY.md before changing provider resolution, auth subscribers, or auth-consuming dashboard UI.
Current Main Google Flow (as of 2026-07-08)
Better Auth is now the only web Google sign-in path for every tenant — both
hashpass.tech (core) and the BSL event tenants. The browser never starts
Supabase Google OAuth directly, and the Directus OAuth bridge is effectively
unused for Google (see
"Do we still need Directus?" below). This
replaces the short-lived "Supabase-first" design from earlier the same day —
that design made Google's account picker show the raw Supabase project URL
(fxgftanraszjjyeidvia.supabase.co) instead of hashpass.tech, and created
two divergent Google identities for the same user (ba_users vs
auth.users), depending on which host happened to resolve.
Web: Better Auth only
useAuth.signInWithOAuth('google')on web always constructs/reuses aBetterAuthProvider(from@hashpass/auth) and callssignInWithOAuth('google')on it first — regardless of whatauthService.getProviderName()resolves to for the tenant (which for core is still the stale'directus'value; see below). IfauthServiceis already Better Auth (BSL tenants), that same instance is reused instead of constructing a second one.- Better Auth's client calls
POST {apiBase}/api/auth/sign-in/social, which returns a Google authorize URL withredirect_uri=<apiBase>/api/auth/callback/google. The browser is redirected there. - Google completes the handshake and redirects back to Better Auth's own
callback (
/api/auth/callback/google), which exchanges the code, sets a secure session cookie, and redirects to the app'scallbackURL(/auth/callback?returnTo=...) — no code/token appears in that URL, since Better Auth already consumed it server-side. app/(shared)/auth/callback.tsxcallshandleOAuthCallback(). Because the sign-in button setauth_signin_method=google_oauthinlocalStorage,useAuth.ts's callback handler checks for a live Better Auth session (getSession({ force: true })) before falling through toauthService.handleOAuthCallback— otherwise it would call Directus's callback handler (since that's still core's resolved provider) against an empty params object and fail with a misleading "Directus did not create a valid session" error.- If Better Auth cannot start or complete the flow, the app reports the
Better Auth error and returns to the login screen. It does not redirect to
Supabase's
/auth/v1/authorizeendpoint.
Native: Better Auth first, Supabase ID-token fallback
Native (Android) still starts with the Google Sign-In SDK account picker, then
exchanges the returned ID token with Better Auth first. Supabase
signInWithIdToken() remains a native-only compatibility fallback, described
below in Native Google Sign-In SDK Flow.
Do We Still Need Directus?
Decision: keep it for now, but it's not being used as auth or CMS today.
Tracked as a pending follow-up in .agents/pending/directus-usage-and-flow.md
— that task covers verifying the one place it could still be silently
load-bearing (server-side token verification on a few API routes), checking
for Directus-only user accounts, and evaluating whether to formally
deprecate it, repurpose it as a CMS, or leave it as-is. It also has a section
evaluating self-hosted alternatives (PocketBase, self-hosted Supabase, etc.)
in case Supabase itself is ever reconsidered.
As of this writing, nothing in the running app actually reaches Directus for a real sign-in:
- Email/OTP sign-in on web talks to Supabase directly
(
supabase.auth.signInWithOtp) or the custom/api/auth/otp+/api/auth/otp/verifyREST endpoints — neither goes throughauthService. - Web Google sign-in now goes through Better Auth only (see above) — Directus
is never attempted for
provider === 'google'on web. - No UI screen calls
authService.signInWithEmailAndPassword(Directus's password flow) or offers github/facebook/twitter buttons. - The only remaining code path that branches on
providerName === 'directus'is inuseAuth.ts's native OAuth-URL-callback parsing (app/(shared)/auth.tsx→signInWithOAuth, around theWebBrowser.openAuthSessionAsyncbranch) — reachable only if a non-Google provider were ever wired up, or as a deep fallback if both native Google SDK and Supabase are unavailable. apps/directus/README.mddescribes it as "Directus for local auth/SSO testing... does not contain application code" — it was never meant to be the production auth backend long-term.SSO_CONFIG.tenants.core.authProvider: 'directus'inpackages/config/src/sso-config.tsis a holdover from an abandoned Supabase→Directus migration plan (see that file's ownMIGRATION_STATUSblock, dated 2025-12-18,migration_phase: 'in_progress', never completed). It still affectsauthService.getProviderName()and a couple of debug/UI branches, but no longer determines which backend actually handles a sign-in for Google or OTP.
Before fully removing it, check whether any existing users still carry
Directus-issued sessions/tokens that would need a migration path, and whether
sso.hashpass.co is relied on by anything outside this app (e.g. an admin
tool). Those are the only reasons this doc doesn't say "delete it now."
Production Requirements
For the Better Auth Google flow (now the default for every tenant on web):
BETTER_AUTH_SECRETBETTER_AUTH_URL=https://api.hashpass.tech/api/auth(server) /EXPO_PUBLIC_BETTER_AUTH_URL=https://api.hashpass.tech/api/auth(client)BETTER_AUTH_DATABASE_URL(falls back toDATABASE_URL/DATABASE_URL_PRODif unset) — must point at a database that already has Better Auth's core tables (ba_users,session,account,verification); see "Schema migration" belowBETTER_AUTH_GOOGLE_CLIENT_ID/BETTER_AUTH_GOOGLE_CLIENT_SECRET(or the existingGOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET)- The OAuth Client in Google Cloud Console must have
<apiBase>/api/auth/callback/googleregistered as an authorized redirect URI for every environment that uses it — e.g.https://api.hashpass.tech/api/auth/callback/googlefor production andhttp://localhost:8081/api/auth/callback/googlefor local web dev. This is separate from any legacy Supabase redirect URI; the web Google flow no longer depends on Supabase's Google provider being enabled. packages/config/src/sso-config.ts'sSSO_CONFIG.cors.origins(Better Auth'strustedOrigins) must include the actual frontend origin(s) —https://hashpass.tech,https://www.hashpass.tech,https://dev.hashpass.techwere missing until 2026-07-08 and would have made Better Auth reject requests from the production core domain.- API Gateway and the Lambda adapter CORS allowlists must include the same
credentialed frontend origins. In particular,
https://dev.hashpass.techmust be allowed byhashpass-dev-http-api; otherwise the browser blockshttps://api-dev.hashpass.tech/api/auth/sign-in/socialduring Google sign-in before Better Auth can start the redirect. - The API Lambda adapter must forward API Gateway v2
event.cookiesinto the FetchCookieheader before handing the request to Expo Router / Better Auth. Better Auth stores the OAuth state in a secure cookie onapi.hashpass.tech; if the callback reaches/api/auth/callback/googlewithout that cookie, the server rejects the Google return asstate_mismatchand the app falls back to/auth.
Supabase compatibility paths (email/OTP and native fallback only):
EXPO_PUBLIC_SUPABASE_URL/EXPO_PUBLIC_SUPABASE_ANON_KEY(or the active profile-specific equivalents)- Supabase redirect allow-list entries for passwordless callbacks
The Directus bridge (/api/auth/oauth/login → Directus → /api/auth/oauth/callback)
still exists in the tree for compatibility but is not reachable from the
Google sign-in button anymore — see "Do we still need Directus?".
Relevant Routes
apps/mobile-app/hooks/useAuth.ts—signInWithOAuth('google')andhandleOAuthCallback(Better-Auth-first routing)packages/auth/src/providers/better-auth.ts—BetterAuthProvider.signInWithOAuth/.handleOAuthCallbackapps/mobile-app/lib/server/better-auth.ts— server-side Better Auth config (socialProviders.google,allowedHosts,trustedOrigins)apps/mobile-app/lib/server/better-auth-route.ts— wraps Better Auth's route handler so auth errors never strand users on the API hostapps/mobile-app/lib/server/better-auth-error-redirect.ts— converts Better Auth/api/auth/error?...and bad redirect targets into frontend/auth?...redirectspackages/infra/lambda/index.js— converts API Gateway v2 events into Fetch requests; must preserve the OAuth state cookie fromevent.cookiesapps/mobile-app/lib/supabase.ts— fallback path onlyapps/mobile-app/app/(shared)/auth/callback.tsxapps/mobile-app/app/api/auth/[...auth]+api.ts— Better Auth's catch-all handler, exported through the wrapper aboveapps/mobile-app/app/api/auth/oauth/login+api.ts/callback+api.ts— Directus bridge (rarely reached now)
Schema Migration
Better Auth's core tables (ba_users, session, account, verification)
are not created by any file under db/migrations/ — that folder only
covers the canonical public.user registry. Each Supabase project/profile
has its own Postgres, so a fresh environment (e.g. a new dev project) needs
this run once:
# From apps/mobile-app — @better-auth/cli needs a plain `auth` export, but
# lib/server/better-auth.ts exports a lazy getAuth() getter (so bundling
# doesn't connect to the DB at import time). Bridge it with a throwaway file:
cat > better-auth.cli-config.ts <<'EOF'
import { loadServerEnvFiles } from './lib/server/load-server-env';
import { getAuth } from './lib/server/better-auth';
loadServerEnvFiles();
const instance = getAuth();
if (!instance) throw new Error('getAuth() returned null — set DATABASE_URL first.');
export const auth = instance;
EOF
npx @better-auth/cli generate --config ./better-auth.cli-config.ts --output ./schema.sql -y
# review schema.sql — should be additive CREATE TABLE/INDEX only — then apply
# it against the target database (e.g. via psql or a small pg script), and
# delete better-auth.cli-config.ts afterward.
Confirmed present in both the local core-development and production
core-production databases as of 2026-07-08 (BSL had already migrated
theirs); this only needs re-running for a genuinely new database.
OTP (One-Time Password) Flow — Native Mobile
HASHPASS native uses a 6-digit OTP code instead of magic links. The Lambda generates a token via Supabase Admin API, stores a mapping, and sends the code via custom email or SMS.
Send (POST /api/auth/otp)
- Call
supabase.auth.admin.generateLink({ type: 'magiclink', email }). - Extract from the response:
properties.hashed_token→tokenHash(SHA256 of the raw token; used with GoTruetoken_hashpath)properties.email_otp→emailOtp(raw token; used with GoTrue{ email, token }path)properties.verification_type→verificationType(usually'magiclink')
- Store
{verificationType}::{tokenHash}::{emailOtp}astoken_hashin theotp_codestable alongside the 6-digitcode, email, and expiry. - Send the 6-digit code via SMTP (Brevo) or Brevo transactional SMS.
Verify (POST /api/auth/otp/verify)
- Look up the
otp_codesrow by email + 6-digit code (not used, not expired). - Parse
token_hashcolumn: split on::to recoververificationType,tokenHash,emailOtp. - Mark the row
used = trueimmediately (replay prevention). - Call GoTrue
/auth/v1/verifydirectly (no Supabase JS client — avoids PKCE fields being injected):- Primary:
{ token_hash: tokenHash, type: verificationType }— the most reliable path for magic link tokens. - Fallback:
{ email, token: emailOtp, type: verificationType }— used if token_hash path fails for a recoverable reason. - Tries multiple
typevalues (magiclink,signup,email) to survive GoTrue version quirks. - Stops immediately if the error is "expired/invalid" (no point retrying an expired token).
- Primary:
- Return the GoTrue session (
access_token,refresh_token) to the client. - Client calls
supabase.auth.setSession()to establish the local session.
Why we bypass the Supabase JS client for verify
supabase.auth.verifyOtp({ token_hash, type }) internally appends code_verifier and other PKCE fields. GoTrue rejects this with "Only the token_hash and type should be provided". Using a raw fetch() to /auth/v1/verify avoids the issue entirely.
Relevant files
apps/mobile-app/app/api/auth/otp+api.ts— send endpointapps/mobile-app/app/api/auth/otp/verify+api.ts— verify endpointapps/mobile-app/db/otp_codes.sql— DB schema for otp_codes table
Native Google Sign-In SDK Flow (Android)
Android builds use the @react-native-google-signin/google-signin SDK (v16) instead of a browser popup when public Supabase config exists and EXPO_PUBLIC_NATIVE_GOOGLE_SIGNIN=true is baked into the bundle at build time.
How it works (updated 2026-07-08: Better Auth first, same as web)
Android native app
└─ signInWithOAuth('google') called
│ (nativeGoogleEnabled=true and Supabase public config is present)
▼
GoogleSignin.hasPlayServices()
▼
GoogleSignin.configure({ webClientId: EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID })
▼
GoogleSignin.signIn() ← system account picker (no browser popup)
│
▼
response.data.idToken ← ID token from Google's servers
│
▼
BetterAuthProvider.signInWithIdToken('google', idToken)
│ POST {apiBase}/api/auth/sign-in/social { provider: 'google', idToken: { token } }
│ (no browser redirect — Better Auth verifies the ID token's signature/audience
│ directly against its configured Google client ID, same credential pair as web)
▼
success? ──yes──▶ Better Auth session established → user logged in
│
no (error/throw)
▼
supabase.auth.signInWithIdToken({ provider: 'google', token: idToken }) ← fallback only
▼
Supabase session established → user logged in
Mirrors the web precedence (see Current Main Google Flow) so native and web users end up under the same Better Auth identity rather than diverging depending on platform. BetterAuthProvider.signInWithIdToken is implemented in packages/auth/src/providers/better-auth.ts using Better Auth's documented "Sign in with ID Token" support — no new native SDK code, no extra user interaction, same GoogleSignin call as before.
Required configuration
| Setting | Value |
|---|---|
EXPO_PUBLIC_NATIVE_GOOGLE_SIGNIN | true (baked into bundle at CI build time) |
EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID | OAuth 2.0 Web client ID from GCP (type: Web application) |
| GCP Android OAuth client SHA-1 | App signing key certificate SHA-1 from Play Console → App integrity → App signing |
Critical: Google Play re-signs the AAB with its own App Signing Key. The SHA-1 registered in GCP must be the App signing key from Play Console, NOT the upload key from your local keystore. Using the upload key SHA-1 causes DEVELOPER_ERROR at runtime.
| Key | SHA-1 |
|---|---|
| App signing key (Play, register this in GCP) | 38:54:0E:C7:01:A7:11:AF:EE:D0:80:B1:EC:D0:E1:09:09:B0:68:79 |
| Upload key (local Fastlane build, do NOT register in GCP) | C1:B7:B9:E6:7F:D1:99:06:16:07:6E:D0:0E:D3:BA:20:12:24:8C:B1 |
Sign-out behavior
GoogleSignin.signOut() is called at the start of every app sign-out (in useAuth.signOut()) so the system account picker always appears on the next login attempt instead of silently reusing the cached account. It is also called when the user clears cache in Settings.
A Reset Google Account button in Settings → Security lets users manually revoke the cached account selection without signing out of the app.
Relevant files
apps/mobile-app/app/_layout.tsx—GoogleSignin.configure()called once on app bootapps/mobile-app/hooks/useAuth.ts—signInWithOAuth('google')native SDK path andsignOut()cache clearingapps/mobile-app/app/(shared)/dashboard/settings.tsx— Reset Google Account button
Fallback
If Play Services are unavailable or the user cancels, the flow falls through to the WebBrowser.openAuthSessionAsync browser OAuth path (unchanged from web).
Troubleshooting
Web Google sign-in returns a Better Auth error before Google opens
This means Better Auth's own attempt failed before it could return Google's authorize URL. Diagnose in this order — each was an actual bug found and fixed on 2026-07-08, and any one of them alone can stop the flow:
curl <apiBase>/api/auth/get-session— if it 500s withz.coerce.boolean(...).meta is not a function(or a minified variant liken.coerce.boolean(...)), Metro'sblockListinapps/mobile-app/metro.config.jsis blockingbetter-auth's own bundledzod@4and falling back to the repo root's mismatchedzod@3. The blockList must exemptbetter-auth/,better-call/, and@better-auth/nestednode_modules. This bug affects the built Lambda bundle too (expo exportuses the same Metro config), not just local dev.- If it 500s with
Host "..." is not in the allowed hosts list— the requesting host+port isn't inDEFAULT_ALLOWED_HOSTSinapps/mobile-app/lib/server/better-auth.ts. Better Auth matches the rawHostheader including the port (localhost:8081, not justlocalhost). - If it 500s with
relation "verification" does not exist(orsession/account/ba_users) — Better Auth's schema was never migrated on that database. See "Schema migration" above. Each Supabase project/profile has its own Postgres, so this can pass on one environment and fail on another. - If
get-sessionreturns200/nullcleanly butsign-in/socialreturnsredirect_uri_mismatchfrom Google — the OAuth Client in Google Cloud Console doesn't have<apiBase>/api/auth/callback/googleregistered as an authorized redirect URI for that host. This is a per-environment, per-domain manual step in Google Cloud Console — nothing in the repo can fix it. Both the local dev port and the productionapi.hashpass.techcallback need to be registered on the same OAuth Client.
"Directus did not create a valid session" after a successful Google consent screen
This means the callback landed correctly but useAuth.ts's callback handler
called authService.handleOAuthCallback (which resolves to Directus for core
on web) instead of checking Better Auth's session first. Confirm
app/(shared)/auth.tsx's handleGoogleSignIn still sets
localStorage.setItem('auth_signin_method', 'google_oauth') before calling
signInWithOAuth, and that useAuth.ts's handleOAuthCallback still checks
for that marker on web before falling through to authService. This isn't a
Directus problem despite the message text — Directus was never actually
involved.
Browser lands on https://api.hashpass.tech/?error=state_mismatch or /api/auth/error?...
This is a Better Auth failure path, not a successful login. It means Better
Auth rejected the callback before app code could restore a session, usually
because the OAuth state cookie was missing, stale, or no longer matched the
callback request.
The production fix shipped on 2026-07-08 is server-side:
apps/mobile-app/lib/server/better-auth-route.tsintercepts requests to Better Auth's catch-all route before and after the framework handler runs.- If the request itself is
/api/auth/error?..., or if Better Auth tries to redirect the browser from/api/auth/*to/with auth error params,apps/mobile-app/lib/server/better-auth-error-redirect.tsrewrites the response to the frontend auth screen instead. - The final browser destination becomes
/auth?error=state_mismatch&message=...&returnTo=...on the frontend origin, not the API host. Local requests stay onhttp://localhost:8081,api-dev.hashpass.techmaps tohttps://dev.hashpass.tech, and production maps tohttps://hashpass.tech.
If this page ever reappears on the API host in production, the wrapper route has regressed or the deployed API bundle is older than the frontend bundle.
Other
- If the browser lands on a Supabase
/auth/v1/authorize?provider=googleURL, web Google has regressed into the removed fallback path. The correct web path starts at/api/auth/sign-in/socialand returns through/api/auth/callback/google. - If the browser lands on
/dashboard/explore?error=oauth_failed..., check the API Lambda logs for the callback request ID. - For
DEVELOPER_ERRORon Android Google Sign-In: verify the SHA-1 in the GCP Android OAuth client is the App signing key SHA-1 from Play Console (not the upload key). - If the system account picker is skipped and the previous account is reused: the user should tap Reset Google Account in Settings → Security, or sign out and back in.
- If login ever reaches the Directus bridge (
/api/auth/oauth/login) unexpectedly forprovider=googleon web, that's itself a bug now — see "Do we still need Directus?".
Logged-out visitors must never render the dashboard (fixed 2026-08-17)
app/events/[eventSlug]/home.tsx — the redirect target for every "open this
event" tap on the public landing page (including the landing hero carousel)
— unconditionally sent every visitor, logged in or not, into
/(shared)/dashboard/explore, a protected route. This wasn't just a
redirect-to-the-wrong-place bug: the dashboard layout's own guard
(app/(shared)/dashboard/_layout.tsx) only ejects an unauthenticated visitor
after a multi-second grace period by design (see
AUTH_BOOTSTRAP_STABILITY.md and the comment
above that effect) — that delay exists to avoid force-unmounting mid-render
during a real transient auth-provider flap, which was crashing natively on
Android. So a genuinely logged-out visitor would see real dashboard content
render for real, for real seconds, before the eventual bounce to /auth.
A second, independent leak had the same effect: components/EventBanner.tsx's
"explore more events" button (shown on a finished/archived event's banner —
reachable from the landing carousel and from
/events/{id}/event-info) pushed straight to
/(shared)/dashboard/explore with no auth check of its own.
Both are fixed the same way — check isLoggedIn from useAuth() before
ever navigating toward the dashboard, and send a logged-out visitor to the
event's own public info page (/events/{id}/event-info, no auth required)
instead:
events/[eventSlug]/home.tsx: authenticated →/(shared)/dashboard/explore?eventId=...(unchanged); logged out →/events/{id}/event-info.EventBanner.tsx's "explore more events" button: same branch, using its owneventIdprop.
app/index.tsx (the app root) was already correctly gated on
isLoggedIn && user and was not affected.
Defense in depth added to dashboard/_layout.tsx itself: the redirect
timer's timing is unchanged (still delayed, still only for the real
transient-flap case the crash fix needs) — but the component now also
refuses to render dashboard content at all when auth has finished loading,
isLoggedIn is false, and there's no recent-auth grace window
(hasRecentAuthSuccess()) that could mean this is just that transient flap
on a real session. In that unambiguous case it renders a plain
LoadingScreen while the existing redirect timer runs its course, instead
of the real dashboard UI. This means any future path that mistakenly
routes a logged-out visitor into /dashboard/* no longer results in visible
protected content, even temporarily — the specific two leaks above are still
the real fix; this is the safety net for the next one.
Local testing note: EXPO_PUBLIC_DEV_AUTH_BYPASS (see
lib/auth/dev-bypass.ts) intentionally overrides all of the above for local
dev convenience — both the entry-point redirects and the dashboard's guard
treat it the same as isLoggedIn. Set it to false in your local
.env/.env.local before testing logged-out behavior, or you'll reproduce
exactly this bug by design and think the fix didn't work.