ReadyNative

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

Pro

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
  1. Create a project at supabase.com, then open [Project Settings] → [API]. Copy the Project URL into .env as EXPO_PUBLIC_SUPABASE_URL and the anon / publishable key as EXPO_PUBLIC_SUPABASE_ANON_KEY. Until both are set auth is disabled: no redirect, useSession() stays unauthenticated, and the sign-in screen shows "Configure Supabase".
  2. In Supabase, open [Authentication] → [URL Configuration] → [Redirect URLs] and add readynative:// (the scheme from your readynative.config.ts) and readynative://new-password. Without this the Google and password-reset round trips never come back to the app. In Expo Go the links start with exp://<lan-ip>:8081/--/ instead, so add exp://** for your dev project only.
  3. Open [Authentication] → [Providers] → [Email] and leave "Confirm email" on (the default). Sign-up then returns needs_verification and the user signs in after clicking the mail.
  4. 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.
  5. 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-authentication config plugin handles the rest.
  6. 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.
  7. Screens call useT() with English keys. If you selected an i18n module and haven't added ru entries, 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.

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

The keys, in one place:

KeyWhere
EXPO_PUBLIC_SUPABASE_URLDashboard → Project Settings → API → Project URL
EXPO_PUBLIC_SUPABASE_ANON_KEYDashboard → 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 code callbacks are handled too.
  • Password reset is a round trip: "Forgot password?" (/(auth)/reset) calls resetPassword(email), whose link opens /(auth)/new-password (passwordResetRedirectUri()). That screen reads the link with Linking.useLinkingURL(), activates the recovery session with startPasswordRecovery(url) (implicit-flow tokens in the fragment, or a PKCE code), and saves the new password with updatePassword() → 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.ts skips new-password so 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 to flowType: "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):

  1. Put both keys in .env, run npx expo start, and open a fresh install: onboarding → /(auth)/sign-in.
  2. Sign up with a new email → toast "Check your email" → confirm the mail → sign in → you land on / and Settings shows the Account card.
  3. Settings → Sign out → back on sign-in.
  4. Sign in with a wrong password → error toast with Supabase's message, no navigation.
  5. On iOS, tap Continue with Apple → native sheet → signed in; the name appears in Settings on the first sign-in.
  6. Tap Continue with Google → system browser → back in the app signed in (this needs the redirect URL from setup step 2).
  7. 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".
  8. Kill and reopen the app → still signed in, session restored from storage.
  9. 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-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): 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.
  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 @supabase/supabase-js expo-apple-authentication expo-auth-session expo-crypto.
  4. Drop the config plugin expo-apple-authentication from .readynative.json → modules.app.expo.plugins (that is where app.config.ts reads it from), then rebuild the dev build.
  5. Unwrap the provider: delete <AuthProvider> and its import from src/providers.tsx.
  6. Remove the env keys EXPO_PUBLIC_SUPABASE_URL, EXPO_PUBLIC_SUPABASE_ANON_KEY from .env, .env.example and your EAS environment, and EXPO_PUBLIC_SUPABASE_URL, EXPO_PUBLIC_SUPABASE_ANON_KEY from src/lib/env.ts.
  7. 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.
  8. 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/supabase/module.json - the same file bun run setup reads, so it is what actually lands in your repo.

Install

bun run setup --auth supabase

Module id: auth/supabase (the default for this category).

Dependencies

PackageVersionKind
@supabase/supabase-js^2.116.0dependency
expo-apple-authentication~57.0.2dependency (expo install)
expo-auth-session~57.0.12dependency (expo install)
expo-crypto~57.0.3dependency (expo install)

Config plugins

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

  • expo-apple-authentication

Environment keys

KeyRequiredServer-onlyExampleDocs
EXPO_PUBLIC_SUPABASE_URLyesnohttps://xyzcompany.supabase.codashboard
EXPO_PUBLIC_SUPABASE_ANON_KEYyesnosb_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):

TypeLinked to userTrackingPurposes
EmailAddressyesnoAppFunctionality
NameyesnoAppFunctionality
UserIDyesnoAppFunctionality

Providers

Rendered in src/providers.tsx (lower order = outermost):

OrderProviderFrom
30AuthProvider@/lib/auth

Compatibility

Doctor checks

  • env: EXPO_PUBLIC_SUPABASE_URL, EXPO_PUBLIC_SUPABASE_ANON_KEY

After setup

  1. Supabase: create a project, copy Project URL + anon/publishable key from Settings → API into .env (EXPO_PUBLIC_SUPABASE_URL, EXPO_PUBLIC_SUPABASE_ANON_KEY).
  2. Supabase: run supabase/migrations/20260921000000_delete_user.sql (SQL editor or supabase db push) - the Settings → Delete account row calls rpc('delete_user').
  3. Supabase: Authentication → URL Configuration → add readynative:// and readynative://new-password (your scheme) to Redirect URLs - Google sign-in and the password-reset link return through them.
  4. Supabase: Authentication → Providers → Apple: enable, add the iOS bundle id to Client IDs (native Sign in with Apple).
  5. Supabase: Authentication → Providers → Google: enable with a Web OAuth client id/secret from Google Cloud Console.
  6. 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.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/hooks/use-auth-redirect.ts
  • src/lib/__tests__/auth-supabase.test.tsx
  • src/lib/auth.ts
  • 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
  • src/server/session.ts
  • supabase/migrations/20260921000000_delete_user.sql
  • supabase/migrations/20260924000000_delete_user_storage.sql

On this page

Get ReadyNative