Expo + Supabase + Auth

PreviousNext

Scaffold an Expo app with BNA UI, a Supabase backend and authentication — password, magic links, email OTP, Google, Apple and GitHub, with protected routes, onboarding and user profiles pre-wired.

Everything from the Supabase starter, plus authentication with the screens already built: six routes under (auth), a two-step onboarding flow, user profiles with avatar uploads, and route guards backed by row level security. This is what npx bna-ui supabase gives you by default.

Create the app

pnpm dlx bna-ui supabase my-app

Create a Supabase project

At supabase.com/dashboard. Note the project reference — the subdomain in https://<ref>.supabase.co.

Answer the two prompts

Your project URL and publishable key, both from Project Settings → API. They are written to .env.local. Press enter to skip and fill the file in yourself later.

It links, migrates and generates types

If the Supabase CLI is on your PATH, the scaffold runs these for you; if not, they are printed as next steps. Nothing fails either way.

pnpm dlx supabase link --project-ref your-project-ref
npx supabase db push
npm run db:types

Allow-list your redirect URLs

In Authentication → URL Configuration → Redirect URLs, add all three. Expo Go uses the first, a build uses the second, and the password-recovery email uses the third:

exp://localhost:8081
my-app://
my-app://reset-password

Deploy the edge functions

pnpm functions:deploy

delete-account is what the delete button in Settings calls. It has to be a function because removing a user needs a secret key.

Pass --skip-supabase to skip the prompts and the CLI steps.

Run it

pnpm dlx expo start

You land on the sign-in screen. Email and password work immediately; the rest need configuration, below.

What you get

On top of the Supabase starter:

lib/
├── supabase.ts             encrypted storage, PKCE, AppState refresh
└── large-secure-store.ts   AES-256 wrapper — SecureStore caps values at 2048 bytes
providers/
└── auth-provider.tsx       session, user, profile, and the deep-link handler
hooks/
├── useProfile.ts           profile updates
└── useAvatarUpload.ts      image → avatars bucket → profiles.avatar_url
app/
├── _layout.tsx             AuthProvider + the Stack.Protected route guards
├── (auth)/
│   ├── sign-in.tsx  sign-up.tsx
│   ├── magic-link.tsx  verify-otp.tsx
│   └── forgot-password.tsx  reset-password.tsx
└── (onboarding)/
    ├── index.tsx           three-step intro carousel
    └── profile.tsx         display name + avatar, sets profiles.onboarded
components/auth/
├── auth-screen.tsx         shared frame — title, body, keyboard avoidance
├── oauth-buttons.tsx       Google / Apple / GitHub, browser PKCE
└── sign-out-button.tsx
supabase/migrations/
├── 0001_profiles.sql       profiles, RLS, handle_new_user trigger
├── 0002_tasks.sql          per-user tasks, RLS, realtime
└── 0003_storage.sql        avatars + files buckets, owner-scoped policies
supabase/functions/
├── hello-world/            runs as the caller
└── delete-account/         runs as admin, identifies the caller from their JWT

Sign-in methods

MethodShips with a screenNeeds
Email + passwordYesSMTP, for the confirmation email
Magic linkYesSMTP
Email OTPYesSMTP, and {{ .Token }} in the email template
GoogleYesClient ID and secret in Authentication → Providers
AppleYesA Services ID and a generated client secret
GitHubYesAn OAuth app

Provider walkthroughs: Google, Apple, email and SMTP.

How the guards work

app/_layout.tsx mounts exactly one route group at a time:

app/_layout.tsx
<Stack.Protected guard={!signedIn}>
  <Stack.Screen name='(auth)' />
</Stack.Protected>
 
<Stack.Protected guard={needsOnboarding}>
  <Stack.Screen name='(onboarding)' />
</Stack.Protected>
 
<Stack.Protected guard={signedIn && !needsOnboarding}>
  <Stack.Screen name='(tabs)' />
  <Stack.Screen name='sheet' />
</Stack.Protected>

Stack.Protected unmounts the screens whose guard is false and redirects away from them, so with no session there is no navigation path into (tabs) — not by deep link either.

That is the convenience layer. The boundary that actually holds is row level security: every policy filters on auth.uid(), so a modified client gets nothing it is not entitled to regardless of what the app renders.

needsOnboarding is profile?.onboarded === false rather than !profile?.onboarded, because the profile row is created by a database trigger and is briefly null right after sign-up. Treating null as "not onboarded" would flash the onboarding screen at returning users.

Session storage

expo-secure-store refuses values larger than 2048 bytes, and a Supabase session — access token, refresh token and the whole user object — is comfortably past that. This is a nasty failure mode: a test account with no metadata can squeak under the limit, so it works in development and breaks the first time a real user signs in.

lib/large-secure-store.ts keeps a 256-bit AES key in SecureStore and the ciphertext in AsyncStorage, which has no size limit:

lib/supabase.ts
export const supabase = createClient<Database>(url, publishableKey, {
  auth: {
    storage: Platform.OS === 'web' ? undefined : new LargeSecureStore(),
    persistSession: true,
    autoRefreshToken: true,
    detectSessionInUrl: Platform.OS === 'web',
    flowType: 'pkce',
  },
});

And because a refresh timer in a backgrounded app is unreliable — iOS suspends it outright — the client is told when the app comes back:

lib/supabase.ts
AppState.addEventListener('change', (state) => {
  if (state === 'active') supabase.auth.startAutoRefresh();
  else supabase.auth.stopAutoRefresh();
});

Without that, a user who leaves the app for an hour returns to expired requests.

OAuth

One code path for all three providers, in components/auth/oauth-buttons.tsx:

components/auth/oauth-buttons.tsx
const redirectTo = makeRedirectUri();
 
const { data } = await supabase.auth.signInWithOAuth({
  provider,
  options: { redirectTo, skipBrowserRedirect: true },
});
 
const result = await openAuthSessionAsync(data.url, redirectTo);
 
if (result.type === 'success') {
  const code = new URL(result.url).searchParams.get('code');
  await supabase.auth.exchangeCodeForSession(code);
}

This is browser-based PKCE. It works in Expo Go and on web, and needs no native modules or per-platform client IDs. The alternative — native sign-in SDKs feeding signInWithIdToken — gives a nicer sheet on iOS but requires a development build; the auth guide covers swapping to it.

skipBrowserRedirect stops supabase-js navigating the page itself, because we want the URL to hand to an auth session that returns control to the app.

Magic links, email confirmations and password recovery all come back into the app as a URL. providers/auth-provider.tsx handles them centrally rather than in a callback route, because the link can arrive while any screen is mounted — and on a cold start, before the router has settled anywhere at all.

providers/auth-provider.tsx
const handleUrl = async (url: string) => {
  const { queryParams } = Linking.parse(url);
 
  const code = queryParams?.code;
  if (typeof code === 'string') {
    await supabase.auth.exchangeCodeForSession(code);
    return;
  }
  // Older projects and some templates return tokens in the #fragment instead.
  // …
};
 
Linking.getInitialURL().then((url) => url && handleUrl(url));
const subscription = Linking.addEventListener('url', ({ url }) =>
  handleUrl(url)
);

Profiles

auth.users belongs to Supabase and you should not write to it. Everything your app knows about a user lives in public.profiles, keyed by the same id, created by a trigger so it exists from the moment the user does:

supabase/migrations/0001_profiles.sql
create function public.handle_new_user()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
begin
  insert into public.profiles (id, email, display_name, avatar_url)
  values (
    new.id,
    new.email,
    coalesce(
      new.raw_user_meta_data ->> 'display_name',
      new.raw_user_meta_data ->> 'full_name',
      new.raw_user_meta_data ->> 'name'
    ),
    coalesce(
      new.raw_user_meta_data ->> 'avatar_url',
      new.raw_user_meta_data ->> 'picture'
    )
  )
  on conflict (id) do nothing;
  return new;
end;
$$;

The coalesce calls pick up the name and picture OAuth providers supply, which is why a Google sign-up arrives with an avatar already set.

Storage

Avatars go to <user-id>/avatar.<ext> in a public bucket. The path is not cosmetic — the policies compare its first segment to the caller's id:

supabase/migrations/0003_storage.sql
create policy "Users can upload their own avatar"
  on storage.objects for insert
  to authenticated
  with check (
    bucket_id = 'avatars'
    and (storage.foldername(name))[1] = auth.uid()::text
  );

The bucket is public so avatars render without a token; writes are still scoped to their owner. There is also a private files bucket, used by nothing, as the shape to copy. See storage.

Environment reference

VariableWhere it livesSet byUsed for
EXPO_PUBLIC_SUPABASE_URL.env.localbna-ui supabaseBuilding the client
EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY.env.localbna-ui supabaseBuilding the client
SUPABASE_SERVICE_ROLE_KEYEdge functionsSupabasedelete-account, which bypasses RLS
SUPABASE_ANON_KEYEdge functionsSupabasehello-world, which runs as the caller

Provider credentials live in the dashboard under Authentication → Providers, not in any file.

Local development

To run the whole stack locally, including a mail catcher that collects every magic link and OTP, install Docker and:

pnpm dlx supabase start          # Studio at http://localhost:54323
npx supabase db reset       # migrations + seed.sql
npm run db:types:local

Sign-up emails land in Inbucket instead of a real inbox, so the entire auth flow is testable without configuring SMTP. supabase/config.toml sets enable_confirmations = false locally so sign-up returns a session immediately.

Before you ship

Turn email confirmations on and configure real SMTP

Local config has confirmations off for convenience. Production should not.

Add your production scheme to the redirect allow-list

If you change scheme in app.json, change this to match, or OAuth redirects are rejected.

Re-read every policy as if you held the publishable key

Because someone will. supabase/migrations/ is your entire access-control surface.

Confirm no secret key reached the bundle

grep -r "sb_secret_" . --exclude-dir=node_modules

Full checklist: deployment.

Next