ReadyNative
AuthPro

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.

Pro

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-modules

Your 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 .env

Create a project at supabase.com, open Project Settings → API and copy two values:

.env
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 -c

You should see: after onboarding, the app lands on the sign-in screen instead of Home.

3. Set up the dashboard

In the Supabase dashboard:

  1. Authentication → URL Configuration → Redirect URLs: add trailmix:// and trailmix://new-password, using the scheme from your readynative.config.ts. In Expo Go the links start with exp://<lan-ip>:8081/--/ instead, so also add exp://** - on your development project only. Without these, the Google and password-reset round trips never come back to the app.
  2. Authentication → Providers → Email: leave "Confirm email" on. Sign-up then asks the user to confirm by email before the first sign-in.
  3. 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.
  4. 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 doctor

You 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:

src/components/signed-in-as.tsx
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:

src/lib/api/me.ts
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 push

The 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 doctor
bun run typecheck

Every 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 with bun 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 - .env isn'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.

On this page

Get ReadyNative