ReadyNative

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

Pro

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
  1. Decide where the API routes are served and put that origin in .env as EXPO_PUBLIC_API_URL: http://<lan-ip>:8081 while npx expo start runs, or https://<project>.expo.app after you deploy. Without it the module is disabled: always unauthenticated, no redirect, and sign-in shows "Configure EXPO_PUBLIC_API_URL".
  2. Generate a server secret with openssl rand -base64 32 and put it in .env as BETTER_AUTH_SECRET, then set BETTER_AUTH_URL to 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.
  3. Swap the database. The shipped server uses better-auth/adapters/memory, kept on globalThis because 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. Replace database: in src/server/auth.ts with a real adapter (pg Pool, Drizzle, Prisma or Kysely), put the connection string in .env as DATABASE_URL, and run npx @better-auth/cli migrate.
  4. Plug an email provider into src/server/email.ts (see "Sending email" below). sendResetPassword in src/server/auth.ts sends 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.
  5. 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 .env as APPLE_CLIENT_ID and APPLE_CLIENT_SECRET, and add ${BETTER_AUTH_URL}/api/auth/callback/apple as the redirect URI. Turn on the Sign in with Apple capability for your app's bundle id while you're in that portal.
  6. For Google sign-in, create a Web OAuth client in [Google Cloud Console] → [APIs & Services] → [Credentials], add ${BETTER_AUTH_URL}/api/auth/callback/google as an authorized redirect URI, and put the pair in .env as GOOGLE_CLIENT_ID and GOOGLE_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.

  1. Run bun run doctor - every row for this module should be green.

The keys, in one place:

KeyWhereGet it
EXPO_PUBLIC_API_URLclientthe API routes origin: http://<lan-ip>:8081 in dev, https://<project>.expo.app after deploy
BETTER_AUTH_SECRETserveropenssl rand -base64 32 - https://www.better-auth.com/docs/installation
BETTER_AUTH_URLserversame origin as EXPO_PUBLIC_API_URL
DATABASE_URLserver (after the swap)your Postgres - https://www.better-auth.com/docs/concepts/database
APPLE_CLIENT_ID / APPLE_CLIENT_SECRETserver, optionalServices 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_SECRETserver, optionalWeb 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_URL is 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-authentication id-token flow) is not wired; the button uses the web OAuth flow through the system browser. appBundleIdentifier is already passed, so you can add the id-token flow client-side.
  • Better Auth warns about a missing BETTER_AUTH_SECRET in dev and refuses to start in production without one.

Check it works (the Examples steps need a tree set up with --with-examples):

  1. Set EXPO_PUBLIC_API_URL=http://<lan-ip>:8081, BETTER_AUTH_URL to the same, and a random BETTER_AUTH_SECRET, then run bun run start.
  2. curl -s $BETTER_AUTH_URL/api/auth/ok returns {"ok":true}.
  3. The app opens on /(auth)/sign-in after onboarding. Tap "Create one" → name, email, password → you land on Home and Settings shows the Account card.
  4. Examples → "Auth session" shows name, email and id; "Sign out" takes you back to sign-in.
  5. 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.
  6. Tap "Forgot password?" → enter the email → "Check your inbox"; with no email provider plugged in, the npx expo start terminal shows [email] No email provider is configured (never the link). With one, the email arrives.
  7. With Apple or Google keys set, "Continue with Google" opens the browser and returns via readynative:// with an authenticated session.
  8. 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-modules

In a finalized tree setup is a stub, so you undo it by hand. Here is everything this module added:

  1. 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.
  2. 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 from modules/auth/none/files/ of a fresh clone of your tier repo - same exports, nothing behind them.
  3. Uninstall the dependencies: bun remove @better-auth/expo better-auth expo-network expo-secure-store.
  4. Drop the config plugin expo-secure-store from .readynative.json → modules.app.expo.plugins (that is where app.config.ts reads it from), then rebuild the dev build.
  5. 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_SECRET from .env, .env.example and your EAS environment, and EXPO_PUBLIC_API_URL from src/lib/env.ts.
  6. Update the privacy declarations: remove this module's entries from .readynative.json → modules.app.expo.ios.privacyManifests, then re-run bun run gen:privacy and revise your store privacy answers.
  7. Check it: bun run typecheck and bun run lint point at anything that still imports the removed files; bun run gen:graph refreshes docs/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-auth

Module id: auth/better-auth.

Dependencies

PackageVersionKind
@better-auth/expo^1.7.5dependency
better-auth^1.7.5dependency
expo-network~57.0.2dependency (expo install)
expo-secure-store~57.0.4dependency (expo install)

Config plugins

Merged into app.config.ts through .readynative.json (modules.app):

  • expo-secure-store

Environment keys

KeyRequiredServer-onlyExampleDocs
EXPO_PUBLIC_API_URLnonohttp://localhost:8081dashboard
BETTER_AUTH_SECRETyesyesgenerate-with-openssl-rand-base64-32dashboard
BETTER_AUTH_URLyesyeshttp://localhost:8081dashboard
DATABASE_URLnoyespostgres://user:pass@host:5432/appdashboard
APPLE_CLIENT_IDnoyescom.acme.app.webdashboard
APPLE_CLIENT_SECRETnoyeseyJ...dashboard
GOOGLE_CLIENT_IDnoyesxxx.apps.googleusercontent.comdashboard
GOOGLE_CLIENT_SECRETnoyesGOCSPX-...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):

TypeLinked to userTrackingPurposes
EmailAddressyesnoAppFunctionality
NameyesnoAppFunctionality
UserIDyesnoAppFunctionality

Compatibility

Doctor checks

  • env: EXPO_PUBLIC_API_URL, BETTER_AUTH_SECRET, BETTER_AUTH_URL

After setup

  1. 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)
  2. 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)
  3. 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
  4. 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
  5. Set the same server keys on EAS Hosting: eas env:set --environment production --name BETTER_AUTH_SECRET --value … --visibility sensitive (EAS Hosting can't deploy secret variables), then eas 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.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/hooks/use-auth-redirect.ts
  • src/lib/__tests__/auth-better-auth-disabled.test.tsx
  • src/lib/__tests__/auth-better-auth.test.tsx
  • src/lib/auth-policy.ts
  • src/lib/auth.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
  • src/server/session.ts

On this page

Get ReadyNative