Supabase Auth
Set up Supabase Auth for auth in an Expo app with ReadyNative: email+password, Apple (native), Google (OAuth) · session on the storage adapter · Expo Go OK
Expo Go: yes
Runs in Expo Go; no dev build needed for this module.
This gives you email/password, Sign in with Apple and Google sign-in on top of @supabase/supabase-js, plus the screens to use them: src/app/(auth)/{sign-in,sign-up,reset,new-password} with src/screens/auth/*, a redirect hook in src/hooks/use-auth-redirect.ts that sends signed-out users to /(auth)/sign-in and signed-in users out of (auth) (onboarding still wins first), and, with --with-examples, an example at src/app/examples/auth.tsx. @/lib/auth keeps the same API whichever option you pick, so auth.useSession() and auth.signOut() read the same here as under Clerk or Better Auth - what you also get from Supabase is a Postgres database and row-level security behind the same account. The session is persisted through the storage adapter (@/lib/storage), so it follows whichever storage module you selected.
Setup
bun run setup --auth supabase- Create a project at supabase.com, then open [Project Settings] → [API]. Copy the Project URL into
.envasEXPO_PUBLIC_SUPABASE_URLand the anon / publishable key asEXPO_PUBLIC_SUPABASE_ANON_KEY. Until both are set auth is disabled: no redirect,useSession()staysunauthenticated, and the sign-in screen shows "Configure Supabase". - In Supabase, open [Authentication] → [URL Configuration] → [Redirect URLs] and add
readynative://(the scheme from yourreadynative.config.ts) andreadynative://new-password. Without this the Google and password-reset round trips never come back to the app. In Expo Go the links start withexp://<lan-ip>:8081/--/instead, so addexp://**for your dev project only. - Open [Authentication] → [Providers] → [Email] and leave "Confirm email" on (the default). Sign-up then returns
needs_verificationand the user signs in after clicking the mail. - Open [Authentication] → [Providers] → [Apple] and enable it. Set Client IDs to your iOS bundle id - the native flow uses
signInWithIdToken, so you don't need a Services ID. - Turn on the Sign in with Apple capability for that bundle id in the [Apple Developer] portal → [Certificates, Identifiers & Profiles] → [Identifiers] → your app id. The
expo-apple-authenticationconfig plugin handles the rest. - Open [Authentication] → [Providers] → [Google] and enable it with a Web OAuth client created in [Google Cloud Console] → [APIs & Services] → [Credentials]. Paste Supabase's callback URL into that client's authorized redirect URIs. The app itself never needs a Google client id.
- Screens call
useT()with English keys. If you selected an i18n module and haven't addedruentries, they render in English - keys fall back to themselves.
Going to production?
Point the app at your production Supabase project: the URL and anon key change, and every redirect URL from step 2 has to exist in that project too. The Google web client needs the production Supabase callback URL added as well.
- Run
bun run doctor- every row for this module should be green.
The keys, in one place:
| Key | Where |
|---|---|
EXPO_PUBLIC_SUPABASE_URL | Dashboard → Project Settings → API → Project URL |
EXPO_PUBLIC_SUPABASE_ANON_KEY | Dashboard → Project Settings → API → anon / publishable key |
Deps: @supabase/supabase-js ^2.116, expo-apple-authentication, expo-auth-session, expo-crypto (expo-web-browser is a core dep). Config plugin: expo-apple-authentication.
Usage
Read the session anywhere:
import { auth } from "@/lib/auth";
const session = auth.useSession();
if (session.status === "authenticated") console.log(session.user.email);Sign in with email or with Apple:
import { signInWithEmail, signInWithApple, canSignInWithApple } from "@/lib/auth";
await signInWithEmail(email, password);
if (canSignInWithApple) await signInWithApple();Sign out, and reach the raw client when you need Postgres:
import { auth, supabase } from "@/lib/auth";
await auth.signOut();
const { data } = await supabase!.from("profiles").select("*");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() (authorization: Bearer <access token>, refreshed first if expired; {} while signed out), and the route resolves the caller with serverAuth.getRequestUser(request) from src/server/session.ts (supabase.auth.getUser(jwt): signature, expiry, and that the user still exists; it reads the same EXPO_PUBLIC_SUPABASE_* keys). 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(). The anon client cannot delete auth users, so the module ships supabase/migrations/20260921000000_delete_user.sql: a security definer function public.delete_user() that deletes auth.users for auth.uid(), granted to authenticated only. Supabase forbids delete from storage.objects in SQL (a trigger added in January 2026; the Storage API is the documented way), so a second migration, supabase/migrations/20260924000000_delete_user_storage.sql, adds public.list_user_objects() (security definer, returns the caller's objects by owner_id), public.account_deletion_ready() and two RLS policies on storage.objects, "readynative: owners read their own objects" (select) and "readynative: owners delete their own objects" (delete) - remove() needs both. Both are scoped to the signed-in user's own files (owner_id = auth.uid()) in every bucket; nobody can read or delete anyone else's objects through them. To narrow them to some buckets, add and bucket_id in ('avatars', 'uploads') to both using clauses in the migration - files in other buckets then survive account deletion, so delete those from your server. Apply both (supabase db push or the SQL editor) - doctor --store notes when the file is missing. deleteAccount() lists the user's files, removes them bucket by bucket with supabase.storage.from(bucket).remove(paths) in batches of 1000, re-lists and throws if anything is left (the account is kept), then calls the delete_user RPC and signOut({ scope: "local" }); useAuthRedirect sends the user to sign-in. Before any of that - and before Settings erases the user's analytics and purchase data on your server - auth.prepareDeleteAccount() calls public.account_deletion_ready() (shipped with the storage migration; true once delete_user() exists) and list_user_objects(), so a missing migration or a broken storage policy stops the deletion while nothing has been touched. If a migration isn't applied (PostgREST PGRST202, or account_deletion_ready() returning false) the error names the migration file to apply. Your own tables clean up with references auth.users (id) on delete cascade; anything else, delete inside the function before the auth.users row.
Gotchas
- The Google flow uses the implicit grant (tokens in the callback URL) as in Supabase's Expo guide; PKCE
codecallbacks are handled too. - Password reset is a round trip: "Forgot password?" (
/(auth)/reset) callsresetPassword(email), whose link opens/(auth)/new-password(passwordResetRedirectUri()). That screen reads the link withLinking.useLinkingURL(), activates the recovery session withstartPasswordRecovery(url)(implicit-flow tokens in the fragment, or a PKCEcode), and saves the new password withupdatePassword()→supabase.auth.updateUser({ password }). An expired or already-used link shows "This link is invalid or has expired" with a button to request a new one. The recovery session is a real sign-in: a user who opens the link and leaves stays signed in on that device.use-auth-redirect.tsskipsnew-passwordso the screen isn't bounced to Home the moment the session starts. With the default implicit flow the link works on any device that has the app; if you switch the client toflowType: "pkce", it only works on the device that asked for it. - Expo Go works for everything here: Sign in with Apple runs in Expo Go on iOS, and Google opens the system browser.
Check it works (the Examples steps need a tree set up with --with-examples):
- Put both keys in
.env, runnpx expo start, and open a fresh install: onboarding →/(auth)/sign-in. - Sign up with a new email → toast "Check your email" → confirm the mail → sign in → you land on
/and Settings shows the Account card. - Settings → Sign out → back on sign-in.
- Sign in with a wrong password → error toast with Supabase's message, no navigation.
- On iOS, tap Continue with Apple → native sheet → signed in; the name appears in Settings on the first sign-in.
- Tap Continue with Google → system browser → back in the app signed in (this needs the redirect URL from setup step 2).
- Tap Forgot password → the mail arrives → the link opens the app at
readynative://new-password→ enter a new password twice → "Password updated" and you land on Home. Sign out, sign in with the new password, sign out again and open the same link → "This link is invalid or has expired". - Kill and reopen the app → still signed in, session restored from storage.
- Examples → Auth session → the JSON shows
status: "authenticated".
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):src/app/(auth)/_layout.tsx,src/app/(auth)/new-password.tsx,src/app/(auth)/reset.tsx,src/app/(auth)/sign-in.tsx,src/app/(auth)/sign-up.tsx,src/app/examples/auth.tsx,src/lib/__tests__/auth-supabase.test.tsx,src/screens/auth/auth-form.ts,src/screens/auth/new-password-screen.tsx,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__/session-supabase.test.ts,supabase/migrations/20260921000000_delete_user.sql,supabase/migrations/20260924000000_delete_user_storage.sql. - 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 @supabase/supabase-js expo-apple-authentication expo-auth-session expo-crypto. - Drop the config plugin
expo-apple-authenticationfrom.readynative.json→modules.app.expo.plugins(that is whereapp.config.tsreads it from), then rebuild the dev build. - Unwrap the provider: delete
<AuthProvider>and its import fromsrc/providers.tsx. - Remove the env keys
EXPO_PUBLIC_SUPABASE_URL,EXPO_PUBLIC_SUPABASE_ANON_KEYfrom.env,.env.exampleand your EAS environment, andEXPO_PUBLIC_SUPABASE_URL,EXPO_PUBLIC_SUPABASE_ANON_KEYfromsrc/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/supabase/module.json - the same file bun run setup reads, so it is what actually lands in your repo.
Install
bun run setup --auth supabaseModule id: auth/supabase (the default for this category).
Dependencies
| Package | Version | Kind |
|---|---|---|
@supabase/supabase-js | ^2.116.0 | dependency |
expo-apple-authentication | ~57.0.2 | dependency (expo install) |
expo-auth-session | ~57.0.12 | dependency (expo install) |
expo-crypto | ~57.0.3 | dependency (expo install) |
Config plugins
Merged into app.config.ts through .readynative.json (modules.app):
expo-apple-authentication
Environment keys
| Key | Required | Server-only | Example | Docs |
|---|---|---|---|---|
EXPO_PUBLIC_SUPABASE_URL | yes | no | https://xyzcompany.supabase.co | dashboard |
EXPO_PUBLIC_SUPABASE_ANON_KEY | yes | no | sb_publishable_... | 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), Auth tokens, IP address (auth logs); shared with Supabase (processor). 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 |
Providers
Rendered in src/providers.tsx (lower order = outermost):
| Order | Provider | From |
|---|---|---|
| 30 | AuthProvider | @/lib/auth |
Compatibility
- Requires
storage=kv-store,mmkv,async-storage
Doctor checks
- env:
EXPO_PUBLIC_SUPABASE_URL,EXPO_PUBLIC_SUPABASE_ANON_KEY
After setup
- Supabase: create a project, copy Project URL + anon/publishable key from Settings → API into .env (EXPO_PUBLIC_SUPABASE_URL, EXPO_PUBLIC_SUPABASE_ANON_KEY).
- Supabase: run supabase/migrations/20260921000000_delete_user.sql (SQL editor or
supabase db push) - the Settings → Delete account row calls rpc('delete_user'). - Supabase: Authentication → URL Configuration → add
readynative://andreadynative://new-password(your scheme) to Redirect URLs - Google sign-in and the password-reset link return through them. - Supabase: Authentication → Providers → Apple: enable, add the iOS bundle id to Client IDs (native Sign in with Apple).
- Supabase: Authentication → Providers → Google: enable with a Web OAuth client id/secret from Google Cloud Console.
- Apple: enable the Sign in with Apple capability for the bundle id (EAS does it on the first build).
Files
19 files copied to the project root
src/app/(auth)/_layout.tsxsrc/app/(auth)/new-password.tsxsrc/app/(auth)/reset.tsxsrc/app/(auth)/sign-in.tsxsrc/app/(auth)/sign-up.tsxsrc/app/examples/auth.tsxsrc/hooks/use-auth-redirect.tssrc/lib/__tests__/auth-supabase.test.tsxsrc/lib/auth.tssrc/screens/auth/auth-form.tssrc/screens/auth/new-password-screen.tsxsrc/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__/session-supabase.test.tssrc/server/session.tssupabase/migrations/20260921000000_delete_user.sqlsupabase/migrations/20260924000000_delete_user_storage.sql