Better Auth
Set up Better Auth for auth in an Expo app with ReadyNative: self-hosted on the API routes · email/password + Apple/Google · Expo Go OK
Expo Go: yes
Runs in Expo Go; no dev build needed for this module.
This is self-hosted auth: no third-party dashboard, no per-user pricing, and the server lives in your own tree at src/server/auth.ts, mounted at /api/auth/* through src/app/api/auth/[...all]+api.ts. It ships email/password plus Apple and Google (each enabled only when its keys are set), the screens src/app/(auth)/{sign-in,sign-up,reset} with src/screens/auth/*, and src/hooks/use-auth-redirect.ts (signed-out → /(auth)/sign-in, signed-in inside (auth) → /). It requires backend/api-routes, since it needs somewhere to run. @/lib/auth keeps the same API whichever option you pick - pick Better Auth when you want the user table to be yours.
Setup
bun run setup --auth better-auth --backend api-routes- Decide where the API routes are served and put that origin in
.envasEXPO_PUBLIC_API_URL:http://<lan-ip>:8081whilenpx expo startruns, orhttps://<project>.expo.appafter you deploy. Without it the module is disabled: alwaysunauthenticated, no redirect, and sign-in shows "Configure EXPO_PUBLIC_API_URL". - Generate a server secret with
openssl rand -base64 32and put it in.envasBETTER_AUTH_SECRET, then setBETTER_AUTH_URLto the same origin you used in step 1. Both go under the# server (never EXPO_PUBLIC)heading in.env.example. Better Auth warns about a missing secret in dev and refuses to start in production without one. - Swap the database. The shipped server uses
better-auth/adapters/memory, kept onglobalThisbecause the Expo dev server re-evaluates the route per request: zero infra, but every user is gone on restart and it can't run on multi-instance hosting. Replacedatabase:insrc/server/auth.tswith a real adapter (pgPool, Drizzle, Prisma or Kysely), put the connection string in.envasDATABASE_URL, and runnpx @better-auth/cli migrate. - Plug an email provider into
src/server/email.ts(see "Sending email" below).sendResetPasswordinsrc/server/auth.tssends through it; until you do, dev logs a notice without the address or the link and production throws, so password reset emails are never silently dropped. - For Apple sign-in, create a Services ID in the [Apple Developer] portal → [Certificates, Identifiers & Profiles] → [Identifiers] → [Services IDs], and a key to sign the client-secret JWT. Put them in
.envasAPPLE_CLIENT_IDandAPPLE_CLIENT_SECRET, and add${BETTER_AUTH_URL}/api/auth/callback/appleas the redirect URI. Turn on the Sign in with Apple capability for your app's bundle id while you're in that portal. - For Google sign-in, create a Web OAuth client in [Google Cloud Console] → [APIs & Services] → [Credentials], add
${BETTER_AUTH_URL}/api/auth/callback/googleas an authorized redirect URI, and put the pair in.envasGOOGLE_CLIENT_IDandGOOGLE_CLIENT_SECRET. Set the Android package name on the client if you build an Android release.
Going to production?
Deploy the API routes (EAS Hosting or your own host), point EXPO_PUBLIC_API_URL and BETTER_AUTH_URL at that https origin, and re-register every OAuth redirect URI against it. Set the server keys in EAS rather than in .env: eas env:set --environment production --visibility sensitive (EAS Hosting can't deploy secret variables). And do not ship the memory adapter.
- Run
bun run doctor- every row for this module should be green.
The keys, in one place:
| Key | Where | Get it |
|---|---|---|
EXPO_PUBLIC_API_URL | client | the API routes origin: http://<lan-ip>:8081 in dev, https://<project>.expo.app after deploy |
BETTER_AUTH_SECRET | server | openssl rand -base64 32 - https://www.better-auth.com/docs/installation |
BETTER_AUTH_URL | server | same origin as EXPO_PUBLIC_API_URL |
DATABASE_URL | server (after the swap) | your Postgres - https://www.better-auth.com/docs/concepts/database |
APPLE_CLIENT_ID / APPLE_CLIENT_SECRET | server, optional | Services ID + key JWT - https://developer.apple.com/account/resources/identifiers/list/serviceId, https://www.better-auth.com/docs/authentication/apple |
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET | server, optional | Web OAuth client - https://console.cloud.google.com/apis/credentials |
Deps: better-auth, @better-auth/expo, expo-secure-store (config plugin), expo-network. Expo Go works. Jest gets root __mocks__ for better-auth/react, @better-auth/expo/client and expo-secure-store (those packages are ESM-only or native), plus tests for the session mapping and the redirect hook.
Usage
Read the session anywhere:
import { auth } from "@/lib/auth";
const session = auth.useSession();
if (session.status === "authenticated") console.log(session.user.email);Email sign-up and sign-in:
import { signUpWithEmail, signInWithEmail } from "@/lib/auth";
await signUpWithEmail(name, email, password);
await signInWithEmail(email, password);Social sign-in and sign-out:
import { auth, signInWithProvider } from "@/lib/auth";
await signInWithProvider("google"); // or "apple"
await auth.signOut();auth.openSignIn() opens /(auth)/sign-in from anywhere; Settings → Developer tools uses it. A signed-in user is sent straight back, so sign out first.
Server routes
API routes never trust a user id from the client. The app attaches credentials with auth.getAuthHeaders() (the session cookie Better Auth keeps in SecureStore, as a cookie header; {} while signed out), and the route resolves the caller with serverAuth.getRequestUser(request) from src/server/session.ts (auth.api.getSession({ headers }) against Better Auth's own session table). It returns null for a missing or invalid credential.
import { auth } from "@/lib/auth";
const res = await fetch(url, { headers: await auth.getAuthHeaders() });import { serverAuth } from "@/server/session";
const user = await serverAuth.getRequestUser(request);
if (!user) return new Response("Unauthorized", { status: 401 });Delete account
Settings ships a confirm-guarded "Delete account" row (App Store Review 5.1.1(v), Play "Account deletion") that calls auth.deleteAccount(options?: { password?: string }) → authClient.deleteUser(); src/server/auth.ts enables it with user: { deleteUser: { enabled: true } }, which is required. Better Auth 1.7 deletes immediately with a fresh session (session.freshAge, default 1 day, measured from session creation). The module sets it explicitly from SESSION_FRESH_AGE_SECONDS in src/lib/auth-policy.ts, shared by the server and the app, and auth.prepareDeleteAccount() applies the same rule to authClient.getSession() before Settings erases anything else (analytics, purchases), so a cancelled password prompt leaves everything as it was. A stale session (in the pre-flight, or SESSION_EXPIRED from the server) becomes code: "REAUTH_REQUIRED"; Settings then shows a "Confirm it's you" password sheet and retries with { password } (a valid password skips the freshness check). INVALID_PASSWORD asks again; CREDENTIAL_ACCOUNT_NOT_FOUND (a social-only user has no password) maps to SIGN_IN_AGAIN, and Settings tells the user to sign in again first. Better Auth deletes the user and their sessions/accounts and the client store flips to signed-out. To confirm by email instead, set sendDeleteAccountVerification in src/server/auth.ts to confirm by email before beforeDelete/afterDelete run. Rows in your own tables that reference the user are yours to remove in afterDelete.
Sending email
src/server/email.ts exports sendEmail({ to, subject, text, html? }), the one place server code sends mail. It ships without a provider: replace const deliver = null with a function that calls yours. Resend (bun add resend, RESEND_API_KEY in .env as a server key):
import { Resend } from "resend";
const resend = new Resend(process.env.RESEND_API_KEY);
const deliver = async (email: Email) => {
const { error } = await resend.emails.send({ from: "App <noreply@your-domain.com>", ...email });
if (error) throw new Error(`[email] Resend: ${error.message}`);
};Postmark (bun add postmark, POSTMARK_SERVER_TOKEN):
import { ServerClient } from "postmark";
const postmark = new ServerClient(process.env.POSTMARK_SERVER_TOKEN ?? "");
const deliver = async (email: Email) => {
await postmark.sendEmail({
From: "noreply@your-domain.com",
To: email.to,
Subject: email.subject,
TextBody: email.text,
HtmlBody: email.html,
});
};Both run on EAS Hosting (they only need fetch); verify your sending domain with the provider first, and set the key with eas env:set --environment production --visibility sensitive. Better Auth awaits the hook, so a provider error reaches the client as a failed "Send reset link" - it's logged server-side too. The reset link opens ${BETTER_AUTH_URL}/api/auth/reset-password/<token>, which redirects to /?token=… on that origin; the page that asks for the new password and calls authClient.resetPassword({ newPassword, token }) is yours to add (a web route, or a deep link into the app).
Gotchas
EXPO_PUBLIC_API_URLis shared with the data modules. When both are selected, the first module's example value wins in.env.example.- Native Sign in with Apple (the
expo-apple-authenticationid-token flow) is not wired; the button uses the web OAuth flow through the system browser.appBundleIdentifieris already passed, so you can add the id-token flow client-side. - Better Auth warns about a missing
BETTER_AUTH_SECRETin dev and refuses to start in production without one.
Check it works (the Examples steps need a tree set up with --with-examples):
- Set
EXPO_PUBLIC_API_URL=http://<lan-ip>:8081,BETTER_AUTH_URLto the same, and a randomBETTER_AUTH_SECRET, then runbun run start. curl -s $BETTER_AUTH_URL/api/auth/okreturns{"ok":true}.- The app opens on
/(auth)/sign-inafter onboarding. Tap "Create one" → name, email, password → you land on Home and Settings shows the Account card. - Examples → "Auth session" shows name, email and id; "Sign out" takes you back to sign-in.
- Sign in with the same email and a wrong password → toast "Invalid email or password"; the correct one lands on Home. Kill and reopen the app → still signed in, from the SecureStore cookie.
- Tap "Forgot password?" → enter the email → "Check your inbox"; with no email provider plugged in, the
npx expo startterminal shows[email] No email provider is configured(never the link). With one, the email arrives. - With Apple or Google keys set, "Continue with Google" opens the browser and returns via
readynative://with an authenticated session. - Restart the dev server → the users are gone. That's the memory adapter, and it's expected until you swap the database.
Remove it
While modules/ exists (a tree set up with --keep-modules), setup does all of it:
bun run setup --auth none --yes --keep-modulesIn a finalized tree setup is a stub, so you undo it by hand. Here is everything this module added:
- Delete the files that are still there (the demo screens are gone already unless you set up with
--with-examples):__mocks__/@better-auth/expo/client.ts,__mocks__/better-auth/react.ts,__mocks__/expo-secure-store.ts,src/app/(auth)/_layout.tsx,src/app/(auth)/reset.tsx,src/app/(auth)/sign-in.tsx,src/app/(auth)/sign-up.tsx,src/app/api/auth/[...all]+api.ts,src/app/examples/auth.tsx,src/hooks/__tests__/use-auth-redirect.test.tsx,src/lib/__tests__/auth-better-auth-disabled.test.tsx,src/lib/__tests__/auth-better-auth.test.tsx,src/lib/auth-policy.ts,src/screens/auth/reset-screen.tsx,src/screens/auth/sign-in-screen.tsx,src/screens/auth/sign-up-screen.tsx,src/screens/examples/auth-example-screen.tsx,src/server/__tests__/email.test.ts,src/server/__tests__/session-better-auth.test.ts,src/server/auth.ts,src/server/email.ts. - Replace, don't delete
src/hooks/use-auth-redirect.ts,src/lib/auth.ts,src/server/session.ts: core code imports them, so swap in the no-op version frommodules/auth/none/files/of a fresh clone of your tier repo - same exports, nothing behind them. - Uninstall the dependencies:
bun remove @better-auth/expo better-auth expo-network expo-secure-store. - Drop the config plugin
expo-secure-storefrom.readynative.json→modules.app.expo.plugins(that is whereapp.config.tsreads it from), then rebuild the dev build. - Remove the env keys
EXPO_PUBLIC_API_URL,BETTER_AUTH_SECRET,BETTER_AUTH_URL,DATABASE_URL,APPLE_CLIENT_ID,APPLE_CLIENT_SECRET,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRETfrom.env,.env.exampleand your EAS environment, andEXPO_PUBLIC_API_URLfromsrc/lib/env.ts. - Update the privacy declarations: remove this module's entries from
.readynative.json→modules.app.expo.ios.privacyManifests, then re-runbun run gen:privacyand revise your store privacy answers. - Check it:
bun run typecheckandbun run lintpoint at anything that still imports the removed files;bun run gen:graphrefreshesdocs/ARCHITECTURE.md.
Reference
Everything below is generated from modules/auth/better-auth/module.json - the same file bun run setup reads, so it is what actually lands in your repo.
Install
bun run setup --auth better-authModule id: auth/better-auth.
Dependencies
| Package | Version | Kind |
|---|---|---|
@better-auth/expo | ^1.7.5 | dependency |
better-auth | ^1.7.5 | dependency |
expo-network | ~57.0.2 | dependency (expo install) |
expo-secure-store | ~57.0.4 | dependency (expo install) |
Config plugins
Merged into app.config.ts through .readynative.json (modules.app):
expo-secure-store
Environment keys
| Key | Required | Server-only | Example | Docs |
|---|---|---|---|---|
EXPO_PUBLIC_API_URL | no | no | http://localhost:8081 | dashboard |
BETTER_AUTH_SECRET | yes | yes | generate-with-openssl-rand-base64-32 | dashboard |
BETTER_AUTH_URL | yes | yes | http://localhost:8081 | dashboard |
DATABASE_URL | no | yes | postgres://user:pass@host:5432/app | dashboard |
APPLE_CLIENT_ID | no | yes | com.acme.app.web | dashboard |
APPLE_CLIENT_SECRET | no | yes | eyJ... | dashboard |
GOOGLE_CLIENT_ID | no | yes | xxx.apps.googleusercontent.com | dashboard |
GOOGLE_CLIENT_SECRET | no | yes | GOCSPX-... | dashboard |
Keys go in .env (see .env.example). Required keys are checked by bun run doctor; Server-only keys have no EXPO_PUBLIC_ prefix, are read only by API routes and never reach the bundle.
Privacy
Play Data safety draft: collects Email address, name (account), Session tokens; shared with Nobody - stored on your own backend. Source of truth: vendor disclosure.
Apple privacy manifest data types (composed into ios.privacyManifests by setup):
| Type | Linked to user | Tracking | Purposes |
|---|---|---|---|
EmailAddress | yes | no | AppFunctionality |
Name | yes | no | AppFunctionality |
UserID | yes | no | AppFunctionality |
Compatibility
- Requires
backend=api-routes
Doctor checks
- env:
EXPO_PUBLIC_API_URL,BETTER_AUTH_SECRET,BETTER_AUTH_URL
After setup
- Better Auth:
openssl rand -base64 32→ BETTER_AUTH_SECRET in .env; BETTER_AUTH_URL = the API routes origin (http://<lan-ip>:8081 in dev, the EAS Hosting URL in prod) - Better Auth: the shipped src/server/auth.ts uses the in-memory adapter (users vanish on restart) - swap it for Postgres/Drizzle/Prisma before shipping (docs.md)
- Apple sign-in: create a Services ID (APPLE_CLIENT_ID) + key (APPLE_CLIENT_SECRET JWT) at developer.apple.com and add BETTER_AUTH_URL/api/auth/callback/apple as a return URL
- Google sign-in: create a Web OAuth client at console.cloud.google.com with BETTER_AUTH_URL/api/auth/callback/google as an authorised redirect URI
- Set the same server keys on EAS Hosting:
eas env:set --environment production --name BETTER_AUTH_SECRET --value … --visibility sensitive(EAS Hosting can't deploysecretvariables), theneas deploy --environment production
Files
24 files copied to the project root
__mocks__/@better-auth/expo/client.ts__mocks__/better-auth/react.ts__mocks__/expo-secure-store.tssrc/app/(auth)/_layout.tsxsrc/app/(auth)/reset.tsxsrc/app/(auth)/sign-in.tsxsrc/app/(auth)/sign-up.tsxsrc/app/api/auth/[...all]+api.tssrc/app/examples/auth.tsxsrc/hooks/__tests__/use-auth-redirect.test.tsxsrc/hooks/use-auth-redirect.tssrc/lib/__tests__/auth-better-auth-disabled.test.tsxsrc/lib/__tests__/auth-better-auth.test.tsxsrc/lib/auth-policy.tssrc/lib/auth.tssrc/screens/auth/reset-screen.tsxsrc/screens/auth/sign-in-screen.tsxsrc/screens/auth/sign-up-screen.tsxsrc/screens/examples/auth-example-screen.tsxsrc/server/__tests__/email.test.tssrc/server/__tests__/session-better-auth.test.tssrc/server/auth.tssrc/server/email.tssrc/server/session.ts