Add sign-in
Add real accounts to an Expo app - Supabase, Clerk or Better Auth - from keys to working sign-up, password reset and account deletion.
Hey - by the end of this page you'll sign up and sign in on the simulator, see your email in Settings, reset a forgotten password, and have signed-out users sent to the sign-in screen automatically.
The screens are already written: src/app/(auth)/sign-in, sign-up and reset with
src/screens/auth/* (Supabase adds new-password, where the reset link lands), plus
src/hooks/use-auth-redirect.ts, which sends signed-out users to /(auth)/sign-in and
signed-in users out of (auth) - onboarding still comes first. You add the keys and the
dashboard settings.
Before you start
- Tier: Pro. The auth modules aren't in Free or Starter.
- Time: about 45 minutes, most of it in the provider's dashboard.
- Runs in: Expo Go, all three options. Sign in with Apple runs natively on iOS, and Google opens the system browser.
- Accounts: a Supabase account. An Apple Developer account only for Sign in with Apple, and a Google Cloud project only for Google sign-in.
- Previous tutorial: Add your first feature.
Picked an auth option at setup? Skip step 1. Step 1 needs modules/, so it works only in a
tree set up with --keep-modules; a finalized tree without auth starts from a fresh clone - see
Can I change a module after setup?.
1. Add the module
bun run setup --auth supabase --yes --keep-modulesYour stack toggle says auth=none, where auth.useSession() always reports signed-out. The
steps below follow Supabase, the default; pick Clerk or Better Auth in the toggle above to switch
them.
Drop --keep-modules to let setup finalize the tree afterwards.
You should see: setup finish with the auth module in its summary, and src/lib/auth.ts plus
src/app/(auth)/ in your tree.
2. Paste the keys
Copy .env.example to .env if you haven't yet - setup lists every key your stack needs in
it:
cp .env.example .envCreate a project at supabase.com, open Project Settings → API and copy two values:
EXPO_PUBLIC_SUPABASE_URL=https://xxxx.supabase.co
EXPO_PUBLIC_SUPABASE_ANON_KEY=eyJ...Until both are set, auth is disabled: no redirect, useSession() stays unauthenticated, and
the sign-in screen shows "Configure Supabase". Both keys are public - row-level security is what
protects your data.
Restart with a clean cache so Expo inlines the new keys:
bun run start -cYou should see: after onboarding, the app lands on the sign-in screen instead of Home.
3. Set up the dashboard
In the Supabase dashboard:
- Authentication → URL Configuration → Redirect URLs: add
trailmix://andtrailmix://new-password, using theschemefrom yourreadynative.config.ts. In Expo Go the links start withexp://<lan-ip>:8081/--/instead, so also addexp://**- on your development project only. Without these, the Google and password-reset round trips never come back to the app. - Authentication → Providers → Email: leave "Confirm email" on. Sign-up then asks the user to confirm by email before the first sign-in.
- Authentication → Providers → Apple (optional): enable it and set Client IDs to your iOS
bundle id. The native flow uses
signInWithIdToken, so no Services ID is needed. Turn on the Sign in with Apple capability for that bundle id in the Apple Developer portal → Certificates, Identifiers & Profiles → Identifiers. - Authentication → Providers → Google (optional): enable it with a Web OAuth client from Google Cloud Console → APIs & Services → Credentials, and paste Supabase's callback URL into that client's authorized redirect URIs. The app never needs a Google client id.
bun run doctorYou should see: every auth row green.
4. Sign up and sign in
Open the app, tap Sign up, and create an account with an email you can read.
A toast says "Check your email". Open the email, confirm, then sign in with the same email and password.
You should see: Home, and in Settings an Account card with your email (or your name with the email under it, when the provider has one), plus Sign out and Delete account. Kill the app and reopen it: you're still signed in.
5. Reset a password
On the sign-in screen, tap Forgot password? and enter your email.
The email's link opens the app on the new password screen (/(auth)/new-password). Enter
the new password twice.
You should see: "Password updated", and the new password works on the next sign-in.
Open the same link again and it says "This link is invalid or has expired" - each link works once.
6. Use the session in your code
@/lib/auth is the same API whichever option you picked, so this code survives a switch.
Here's a small component that shows who's signed in - drop it into any screen, for example above
the form in the Notes screen from Add your first feature:
import { Text } from "@/components/ui";
import { auth } from "@/lib/auth";
export function SignedInAs() {
const session = auth.useSession();
if (session.status !== "authenticated") return null;
return <Text variant="caption">Signed in as {session.user.email ?? session.user.id}</Text>;
}session.status is "loading", "unauthenticated" or "authenticated", and only the last one
has a user (id, and email / name when the provider has them).
With backend=api-routes, call your own API as that user by attaching the credentials the
server checks:
import { auth } from "@/lib/auth";
import { env } from "@/lib/env";
export async function fetchMe(): Promise<{ id: string }> {
const res = await fetch(`${env.API_URL}/api/me`, { headers: await auth.getAuthHeaders() });
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return (await res.json()) as { id: string };
}/api/me is yours to write: resolve the caller with serverAuth.getRequestUser(request) from
src/server/session.ts, and never trust a user id sent by the app. Deploy your API routes covers
the hosting side.
You should see: "Signed in as you@example.com" where you placed <SignedInAs />.
7. Delete the account
The App Store and Google Play both require in-app account deletion, and Settings ships it.
The anon key can't delete users, so the module ships two SQL migrations in
supabase/migrations/: delete_user(), and the storage clean-up that runs before it. Apply
them once per Supabase project. The quickest way is the dashboard: open SQL Editor, paste
the contents of each file in order, and Run. Or use the Supabase CLI:
bunx supabase login
bunx supabase link --project-ref your-project-ref
bunx supabase db pushThe project ref is the xxxx in your EXPO_PUBLIC_SUPABASE_URL.
Now open Settings → Delete account and confirm.
You should see: a success toast and the sign-in screen. Signing in with the old credentials now fails.
Check it
bun run doctorbun run typecheckEvery auth row should be green, and typecheck should pass.
If it doesn't work
- The sign-in screen says "Configure …" - a key is missing or Metro still has the old env.
Check
.env, then restart withbun run start -- -c. - Google sign-in or the reset link never comes back to the app in Expo Go - add
exp://**to the Supabase redirect URLs; see Supabase sign-in never comes back in Expo Go. - Delete account fails and names a migration file - that migration isn't applied to this Supabase project yet. Run it (step 7) and try again; nothing was deleted.
- Sign in with Apple errors out - the Sign in with Apple capability isn't on for the bundle
id you're running. Dev builds use
<your id>.dev, so register that one too. - Works locally, signed out in a build -
.envisn't uploaded to EAS; set the keys there. See A key works locally but not in an EAS build.
More in Troubleshooting.
Congrats 🎉
Your app has accounts: sign-up, sign-in, password reset and account deletion, with the session persisted through your storage adapter and the redirect hook guarding the app. To take the module out again, see Supabase → Remove it.
Next, charge for it: Add a paywall.
Add your first feature
Build a Notes tab end to end - a route, a data hook against a public API, a persisted drafts store, a validated form, a list and a test.
Add a paywall
Charge for your Expo app with RevenueCat, Adapty or Stripe Checkout - from store agreements to a sandbox purchase that unlocks a feature.