Skip to content

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_idsubject_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.tsresolveAppIdentity(table, subject, email): link by auth_user_id, else by email, backfilling privy_id. - src/utils/betterAuthToken.tsverifyBetterAuthToken(): 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 requireVerifiedPrivyUserMiddlewarerequireVerifiedUserMiddleware, getAuthenticatedPrivyEmailgetAuthenticatedUserEmail. merchantAppAuthMiddleware accepts a Better Auth JWT (primary) with the legacy HS256 token as fallback for un-migrated terminals.

authControllerlogin/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 treasurysrc/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 old usePrivy() surface so the sweep stayed mechanical.
  • The ~30 API calls flow through 2 central token getters (lib/api/utils.ts useAuthToken, lib/api/index.ts useAuthenticatedApi) now returning the Better Auth JWT.
  • Google login (signIn.social); PrivyProvider/privy-provider.tsx/privy-wallets.ts removed; Privy dep dropped.
  • ⚠️ hooks/usePaymentProcessor.ts did 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
Better Auth must also set crossSubDomainCookies (domain .sagio.io) so the dashboard on app.sagio.io can call /auth/token on api.sagio.io. CORS credentials are already enabled.

Terminalapps/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)

  1. Workers + Neon runtime for Better Auth. We use drizzle-orm/neon-http — it does not support multi-statement transactions. If Better Auth needs them, switch src/auth/db.ts to drizzle-orm/neon-serverless (WebSocket Pool). Smoke-test this first on deploy.
  2. Google OAuth round-trip (both dashboard and terminal) — never run end-to-end.
  3. Cross-origin /auth/token from the dashboard — depends on crossSubDomainCookies.
  4. Circle treasury — the REST/WebCrypto client is written but unrun (no creds).
  5. Terminal — never compiled (no Android build here). Watch for Credential Manager API and the sign-in/social idToken body shape.
  6. Pre-existing apps/dashboard/lib/api/orders.test.ts failures (10) are not ours — they fail with our changes stashed.

  1. Wire the secrets (§5) and run the smoke-tests (§6). This validates the whole backbone before more UI work.
  2. Wallet (apps/wallet) — the big one. Better Auth login (reuse the dashboard auth-client pattern; it's Capacitor so wire the OAuth deep-link via the existing appUrlOpen handler, repointed off privy_oauth_*). Then replace Privy embedded/smart-wallet signing with Circle user-controlled wallets (createUser → userToken → PIN/passkey wallet; @circle-fin/w3s-pw-web-sdk in the web view). This re-provisions every user's wallet — fine because we're on Base Sepolia (testnet), so no fund sweep.
  3. Merchant (Capacitor) — it wraps the merchant web (dashboard) build; mostly it needs the OAuth deep-link callback handling, same as the wallet.
  4. privy_idsubject_id rename (#10) — do this surgically against a real DB, not a global sed: 274 refs, most in raw SQL strings tsc can'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 from SessionManager). Don't rely on the session cookie for API auth.
  • Drizzle is scoped to Better Auth's tables only. The rest of apps/api is still raw SQL — don't "convert" it.
  • Circle SDK is Node-only. On Workers, call the REST API directly with fetch + WebCrypto (see circleWalletService.ts for the entity-secret ciphertext pattern). All Circle write calls need a UUID idempotencyKey.
  • "Privy" still appears in DTO field names (privy_id/privyId) and the DB column — that's the deferred #10 rename, 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.