Auth Migration Handoff — Privy → Better Auth + Circle¶
For: Chris
Status: Backend + Dashboard + Terminal done. Wallet, Merchant (Capacitor), and the privy_id rename remain.
TL;DR: We're replacing Privy with two independent layers — Better Auth (self-hosted identity, Google-only) and Circle Wallets (custody/signing). The backend is 100% Privy-free and verified. Two web/native surfaces are migrated. Nothing is deployed yet — it needs the secrets in §5 and the runtime smoke-tests in §6 before cutover.
1. Why & the target architecture¶
We left Privy for pricing/scalability, and split auth from wallet custody so each is independently swappable.
- Identity → Better Auth (self-hosted in
apps/api, Google sign-in only, no email/OTP). Issues an ES256 JWT that every surface + the backend verify statelessly against JWKS using@tsndr/cloudflare-worker-jwt. - Custody/signing → Circle Programmable Wallets. Treasury = a developer-controlled wallet; consumer scan-to-pay = user-controlled wallets (not built yet).
Key identity decision — we do NOT rewrite privy_id. It's a hard FK target from ~12 tables (no ON UPDATE CASCADE) and the denormalized tenant key on orders/products. Instead every identity row (merchants/customers/admins) gained an auth_user_id column (the Better Auth user id). On first login we link by verified email and backfill privy_id with the Better Auth subject only when it was null. Downstream tenant keying is unchanged.
2. Scorecard¶
| Surface | State | Notes |
|---|---|---|
Backend (apps/api) |
✅ Done, verified | 0 @privy-io imports, dep removed, tsc clean, 205 tests pass |
Dashboard (apps/dashboard) |
✅ Done, verified | tsc clean; needs a runtime pass with real Google creds |
Terminal (apps/terminal, Kotlin) |
✅ Done, structural | Needs an Android/Gradle build to compile-verify |
Wallet (apps/wallet) |
⏳ Not started | Biggest — Better Auth + Circle signing rewrite; needs Circle creds |
| Merchant (Capacitor) | ⏳ Not started | Wraps the dashboard build; needs OAuth deep-link handling |
privy_id→subject_id rename |
⏳ Not started | Surgical, DB-tested; NOT a global find-replace |
3. What's done — backend (apps/api)¶
Better Auth foundation
- src/auth/betterAuth.ts — the instance. getAuth() is lazy/memoized — on Cloudflare Workers DATABASE_URL+secrets are injected per-request (injectWorkerEnv), so building at import crashes cold start. Never construct at module load.
- src/auth/{db,schema}.ts — Drizzle adapter scoped to Better Auth's own tables only (rest of the app stays raw SQL). Schema generated via bunx @better-auth/cli generate (snake_case columns).
- src/auth/identity.ts — resolveAppIdentity(table, subject, email): link by auth_user_id, else by email, backfilling privy_id.
- src/utils/betterAuthToken.ts — verifyBetterAuthToken(): JWKS verify via @tsndr, cached. JWT plugin is configured ES256 (Better Auth's default EdDSA isn't supported by that verifier).
- src/migrations/create_better_auth_tables.sql + add_auth_user_id.sql, registered in utils/setupDb.ts. Prod applies migrations out-of-band (setupDb only runs in non-prod).
- Mounted at /api/auth/* in routes/index.ts (Hono resolves the legacy literal /auth/login etc. ahead of the wildcard).
Middlewares (auth.ts, adminAuth.ts, merchantAppAuth.ts) — all Better-Auth-only. Renamed requireVerifiedPrivyUserMiddleware→requireVerifiedUserMiddleware, getAuthenticatedPrivyEmail→getAuthenticatedUserEmail. merchantAppAuthMiddleware accepts a Better Auth JWT (primary) with the legacy HS256 token as fallback for un-migrated terminals.
authController — login/getMe/createMerchant/createCustomer now derive identity from the verified token (c.get('user').userId), not the request body. resolveOrCreateMerchant links/creates via resolveAppIdentity. Contract change: /auth/login + /auth/me require a Bearer JWT and no longer read privyId from the body.
Circle treasury — src/services/circleWalletService.ts replaces the old privyWalletService (deleted). It's a Workers-safe REST client (fetch + WebCrypto RSA-OAEP for the entity-secret ciphertext) — the Circle SDK is Node-only and not Workers-safe. Same interface (distributeDnzdTokens, transferDnzdAmount).
Cleanup — deleted legacyPrivyClient.ts; readerEmail Privy helper removed; hackathon.ts admin gate uses verifyBetterAuthToken + a HACKATHON_ADMIN_EMAILS env allowlist. Auth tests ported to Better Auth.
4. What's done — dashboard & terminal¶
Dashboard (apps/dashboard)¶
lib/auth-client.ts— Better Auth React client +jwtClient;getJwt()fetches the JWKS-verifiable JWT from${API}/auth/token(cached,credentials: include);useAuth()shim mirrors the oldusePrivy()surface so the sweep stayed mechanical.- The ~30 API calls flow through 2 central token getters (
lib/api/utils.tsuseAuthToken,lib/api/index.tsuseAuthenticatedApi) now returning the Better Auth JWT. - Google login (
signIn.social);PrivyProvider/privy-provider.tsx/privy-wallets.tsremoved; Privy dep dropped. - ⚠️
hooks/usePaymentProcessor.tsdid sign crypto via Privy wallets — that branch is deferred to Circle with an explicit user-facing message; fiat/open-banking integrations (BlinkPay / Akahu) have been removed pending a reviewed replacement.
Terminal (apps/terminal, native Kotlin)¶
Chose Credential Manager over AppAuth: getGoogleIdOption(serverClientId = <Better Auth web client>) returns a Google ID token whose audience already matches Better Auth, so sign-in/social accepts it directly — no backend audience change.
- Flow: Credential Manager → Google ID token → BetterAuthService.signInWithGoogle (/auth/sign-in/social) → session token (set-auth-token header) → SessionManager.refreshJwt via /auth/token → JWT bearer on merchant-app calls.
- New: auth/{GoogleSignInManager,SessionManager}, network/BetterAuthService, viewmodel/AuthViewModel, ui/screens/LoginScreen; MainActivity nav-gates on authRepository.isAuthenticated().
- Removed the entire API-key path (Settings entry, BuildConfig SAGIO_API_KEY/SECRET, X-Api-Key header).
5. Config & secrets you need to provide¶
Google Cloud — an OAuth consent screen + clients:
- A Web OAuth client → this is the Better Auth "Google client" (GOOGLE_CLIENT_ID/SECRET). Its redirect must include ${API}/api/auth/callback/google.
- A Android OAuth client for the terminal (package io.sagio.merchant + the signing SHA-1). The terminal uses the web client id as serverClientId.
Backend (apps/api)
BETTER_AUTH_SECRET=<random 32+ chars>
BETTER_AUTH_URL=https://api.sagio.io # origin, no trailing /api
GOOGLE_CLIENT_ID=<web client id>
GOOGLE_CLIENT_SECRET=<web client secret>
HACKATHON_ADMIN_EMAILS=chris@...,... # optional, hackathon console gate
# Circle (treasury)
CIRCLE_API_KEY=...
CIRCLE_ENTITY_SECRET=<32-byte hex, registered with Circle>
CIRCLE_TREASURY_WALLET_ID=<developer-controlled SCA wallet>
CIRCLE_BLOCKCHAIN=BASE-SEPOLIA
crossSubDomainCookies (domain .sagio.io) so the dashboard on app.sagio.io can call /auth/token on api.sagio.io. CORS credentials are already enabled.
Terminal — apps/terminal/local.properties: GOOGLE_WEB_CLIENT_ID=<web client id>.
Circle setup: create API key → generateEntitySecret() → registerEntitySecretCiphertext() (save the recovery file), create a wallet set + a developer-controlled SCA wallet on Base, and a Gas Station policy so users/treasury don't need native gas.
6. Verification gaps (what's NOT runtime-tested)¶
- Workers + Neon runtime for Better Auth. We use
drizzle-orm/neon-http— it does not support multi-statement transactions. If Better Auth needs them, switchsrc/auth/db.tstodrizzle-orm/neon-serverless(WebSocket Pool). Smoke-test this first on deploy. - Google OAuth round-trip (both dashboard and terminal) — never run end-to-end.
- Cross-origin
/auth/tokenfrom the dashboard — depends oncrossSubDomainCookies. - Circle treasury — the REST/WebCrypto client is written but unrun (no creds).
- Terminal — never compiled (no Android build here). Watch for Credential Manager API and the
sign-in/socialidToken body shape. - Pre-existing
apps/dashboard/lib/api/orders.test.tsfailures (10) are not ours — they fail with our changes stashed.
7. Remaining work (recommended order)¶
- Wire the secrets (§5) and run the smoke-tests (§6). This validates the whole backbone before more UI work.
- Wallet (
apps/wallet) — the big one. Better Auth login (reuse the dashboardauth-clientpattern; it's Capacitor so wire the OAuth deep-link via the existingappUrlOpenhandler, repointed offprivy_oauth_*). Then replace Privy embedded/smart-wallet signing with Circle user-controlled wallets (createUser → userToken → PIN/passkey wallet;@circle-fin/w3s-pw-web-sdkin the web view). This re-provisions every user's wallet — fine because we're on Base Sepolia (testnet), so no fund sweep. - Merchant (Capacitor) — it wraps the merchant web (dashboard) build; mostly it needs the OAuth deep-link callback handling, same as the wallet.
privy_id→subject_idrename (#10) — do this surgically against a real DB, not a globalsed: 274 refs, most in raw SQL stringstsccan't check, and some are external API field names (userDetails.privyId) that are part of the wire contract. Rename the DB column + internal SQL first; leave wire field names until the frontends update.
8. Decisions & gotchas worth knowing¶
- Better Auth JWT, not session cookie. The backend verifies the ES256 JWT via JWKS. Clients must send that JWT as
Authorization: Bearer(the dashboard fetches it from/auth/token; the terminal fromSessionManager). Don't rely on the session cookie for API auth. - Drizzle is scoped to Better Auth's tables only. The rest of
apps/apiis still raw SQL — don't "convert" it. - Circle SDK is Node-only. On Workers, call the REST API directly with
fetch+ WebCrypto (seecircleWalletService.tsfor the entity-secret ciphertext pattern). All Circle write calls need a UUIDidempotencyKey. - "Privy" still appears in DTO field names (
privy_id/privyId) and the DB column — that's the deferred#10rename, not live Privy code. - Persistent context lives in the repo; the migration decisions are also captured in the assistant's project memory.
Ping me (or re-run the assistant on apps/wallet) when the secrets are in and you want the wallet + Circle work done.