Why Supabase Fits the Expo Builder Stack
Supabase is a hosted Postgres database with a REST and realtime API, built-in auth, and a generous free tier. For indie builders shipping Expo apps with Claude Code, it removes the backend problem entirely: you get a real relational database, email and OAuth sign-in, file storage, and Edge Functions, all accessible from a single JavaScript client. You do not run a server. You do not write migrations by hand. You point your app at a project URL and an anon key and start querying.
The Supabase JS client works in React Native with one adjustment: it needs a custom storage adapter because its default session store (localStorage) does not exist on mobile. The correct replacement is Expo SecureStore, which encrypts session tokens at rest using the device's secure enclave. This is also what Apple and Google expect - storing auth tokens in unencrypted AsyncStorage is a rejection risk on apps that handle personal data.
Install and Configure the Supabase Client
Start with two packages: the Supabase JS client and the Expo SecureStore adapter. If you are using Expo managed workflow, SecureStore is already available - you just need to install it. The JS client is a plain npm package with no native code, so no config plugin or EAS Build is required for it.
- Run: npx expo install @supabase/supabase-js expo-secure-store
- Create a file at lib/supabase.ts (or utils/supabase.ts - pick one location and be consistent). This file exports your configured Supabase client as a singleton so every part of your app imports the same instance.
- Write the SecureStore adapter: it implements the get, set, and remove methods the Supabase client's auth module calls to persist the session. SecureStore operations are async, so the adapter returns Promises. The Supabase client handles the async path correctly when you pass the adapter via the auth.storage option.
- Set auth.autoRefreshToken to true and auth.persistSession to true so the client refreshes expired tokens automatically and saves the refreshed session back to SecureStore.
- Add your Supabase project URL and anon key to your environment variable setup. In Expo, these go in .env.local as EXPO_PUBLIC_SUPABASE_URL and EXPO_PUBLIC_SUPABASE_ANON_KEY. The EXPO_PUBLIC_ prefix makes them available in your app bundle at build time.
Wire Auth Into Your Expo App
Once the client is configured, you need two things in your app: a way to sign users in and out, and a way to read the current session so you can gate screens behind authentication. Supabase auth returns a session object with an access token and a user object. The client fires an onAuthStateChange listener whenever the session changes - on sign-in, sign-out, token refresh, or app resume.
- Create an AuthProvider component that wraps your app. Inside it, call supabase.auth.getSession() on mount to load the persisted session from SecureStore, then subscribe to supabase.auth.onAuthStateChange to update your session state whenever it changes.
- Expose the session (and a loading boolean) via a React context so any component can call useAuth() and know whether the user is logged in.
- In your Expo Router layout file, read the session from context and redirect to /login if there is none, or to your main app if there is one. Expo Router's Redirect component makes this two lines.
- For email sign-in: call supabase.auth.signInWithPassword({ email, password }). For sign-up: supabase.auth.signUp({ email, password }). Both return a data object with the session and user, and an error object you should surface to the user if it is not null.
- For sign-out: call supabase.auth.signOut(). The onAuthStateChange listener fires immediately, your context updates, and the layout redirect handles the rest.
The auth flow itself is the same pattern whether you are building a simple notes app or a complex multi-user platform. The only thing that changes is the sign-in UI and whether you add OAuth providers. Claude Code can scaffold the full AuthProvider, the useAuth hook, the login screen, and the layout redirect from a single prompt describing your app's auth requirements.
Enable Row-Level Security Before You Write Any Data
Row-Level Security is a Postgres feature that lets you attach a policy to a table controlling which rows each database user can see or modify. In Supabase, every request from your app runs as the anon role (unauthenticated) or the authenticated role (signed-in users). RLS policies evaluate against those roles and the user's JWT, which Supabase passes automatically when the user is signed in.
The rule: enable RLS on every table the moment you create it. An unprotected table is fully readable and writable by anyone with your anon key. Since that key is in your app binary, treating an unprotected table as private is not a security model - it is a wishful one. RLS policies are also what Apple auditors and security researchers check when looking at how apps store user data.
- In the Supabase dashboard: open your table, click the RLS tab, and toggle Enable RLS. Do this before you run any insert or before you give the table a real URL in your app.
- Write a SELECT policy for authenticated users that restricts rows to the current user: using (auth.uid() = user_id). This assumes your table has a user_id column that stores the Supabase user UUID. Every user can only see their own rows.
- Write an INSERT policy: with check (auth.uid() = user_id). This prevents a user from inserting a row with someone else's user_id.
- Write UPDATE and DELETE policies with the same auth.uid() = user_id check. Four policies, one per operation, is the complete set for a standard per-user table.
- Test each policy in the Supabase SQL editor using the 'Test as authenticated user' feature before you connect your app. A policy bug discovered in the dashboard takes 30 seconds to fix; one discovered in production review takes longer.
Query the Database From Your App
With RLS enabled and the client configured with SecureStore, every query your signed-in user makes automatically scopes to their rows. You do not pass a user ID as a query parameter. You do not filter by user in your app code. Postgres evaluates the RLS policy on the server and returns only the rows the policy allows. If the user is not signed in, they get nothing from an RLS-protected table - which is exactly what you want.
- Select rows: const { data, error } = await supabase.from('your_table').select('*'). Returns only rows the RLS policy allows for the current user.
- Insert a row: await supabase.from('your_table').insert({ user_id: session.user.id, ...yourFields }). Include user_id explicitly so the INSERT policy check passes.
- Update a row: await supabase.from('your_table').update({ field: value }).eq('id', rowId). RLS prevents updating rows that do not belong to the current user even if a user constructs the update manually.
- Delete a row: await supabase.from('your_table').delete().eq('id', rowId). Same protection applies.
- Handle errors: every Supabase query returns { data, error }. Check error before using data. Surface auth errors (status 401) as a sign-out trigger so the app does not get stuck in a broken auth state.
For realtime updates - subscribing to new rows as they are inserted by another device or a server process - Supabase provides a Channels API. Call supabase.channel('your_table').on('postgres_changes', ...).subscribe(). Realtime also respects RLS, so subscribers only receive events for rows they are allowed to see. Clean up channel subscriptions in your useEffect cleanup function to avoid memory leaks and unexpected behavior when a screen unmounts.
Common Mistakes and How to Avoid Them
Shipping a Supabase-backed Expo app for the first time surfaces a predictable set of mistakes. Most of them come from treating Supabase like a server-side database where your backend code controls access, rather than a client-direct database where the database itself controls access via RLS.
Common Supabase + Expo mistakes
| Mistake | What goes wrong | Fix |
|---|---|---|
| Storing the session in AsyncStorage instead of SecureStore | Auth tokens stored in plaintext; readable by other apps on a rooted device; potential rejection risk for apps handling personal data | Use the SecureStore adapter when configuring the Supabase client |
| Creating a table and forgetting to enable RLS | Every authenticated user can read and modify every row via the anon key in your app binary | Enable RLS in the Supabase dashboard immediately when you create the table, before you run any queries |
| Filtering by user ID in your app code instead of via RLS | A user who inspects your requests can remove the filter and read other users' data | Let RLS handle the filter on the server; never rely on client-side filtering as a security boundary |
| Not handling the initial session load before rendering protected screens | The user sees a flash of the login screen on every cold launch even though they are already signed in | Show a loading state while getSession() resolves; only redirect once you know the session state |
| Using the service role key in your app | Service role key bypasses RLS entirely; if it leaks, your entire database is exposed | Never use the service role key in your Expo app; it belongs only in server-side code you control |
What Claude Code Handles in This Stack
The Supabase + Expo setup has a predictable shape that Claude Code handles well: the client file with the SecureStore adapter, the AuthProvider and useAuth hook, the login and signup screens, and the RLS policy SQL for a table you describe. These are the parts that are mechanical to write but tedious to get exactly right - the async SecureStore adapter, the onAuthStateChange cleanup, the correct policy syntax.
What Claude Code cannot decide for you is your data model and your access rules. A policy that says 'users can only see their own rows' is simple and Claude Code writes it correctly. A policy that says 'users in the same organization can see each other's rows, but only managers can edit' requires you to design the organization membership table and the manager role first. Describe that design to Claude Code and it will write the policies - but the design itself is your call.
TL;DR
Set up Supabase with Expo by installing @supabase/supabase-js and expo-secure-store, writing a SecureStore adapter for the auth storage option, and exporting a single configured client from lib/supabase.ts. Wrap your app in an AuthProvider that reads the persisted session on mount and listens to onAuthStateChange. Enable Row-Level Security on every table before you query it - the anon key is in your binary, so RLS is your actual security boundary. Use auth.uid() = user_id policies to scope every read, insert, update, and delete to the current user. Claude Code writes all the boilerplate; you design the data model and the access rules.
Short, practical drops on app ideas, validating fast, store listings, and passing app review. No spam, unsubscribe anytime.
Frequently asked questions
Is it safe to put my Supabase URL and anon key in my Expo app?
Yes - the anon key is designed to be public and is safe to embed in your app binary. It is not a secret key. What keeps your data safe is Row-Level Security on your database tables. Never put the service role key in your app; that key bypasses RLS and must stay on the server side only.
Why do I need SecureStore instead of AsyncStorage for the Supabase session?
AsyncStorage is unencrypted and readable by other apps on a rooted device. SecureStore encrypts data using the device's secure enclave. For auth tokens - which are effectively passwords for your user's account - SecureStore is the correct storage layer on mobile. It is also what Apple and Google expect when you handle personal account data.
Can I use Supabase OAuth (Google, Apple) with Expo?
Yes. Supabase supports OAuth providers, and Expo supports them via expo-auth-session or deep-link callbacks. Apple Sign-In is required by App Store guidelines if your app offers any other social sign-in, so you will need to add it alongside Google or other providers. The Supabase docs have platform-specific setup for each provider.
Do I need a paid Supabase plan to launch an app?
The Supabase free tier includes 500 MB of database storage, 2 GB of file storage, 50,000 monthly active users, and 2 GB of data transfer. For most indie apps launching their first version this is enough to validate without spending anything. Upgrade when your active user count or storage exceeds those limits, not before.
What is the difference between Supabase's anon key and the service role key?
The anon key is used by your app's unauthenticated and authenticated users. It respects Row-Level Security policies. The service role key bypasses RLS and has full admin access to your database. The service role key belongs only in server-side code you fully control - never in a mobile app binary.
Last reviewed by David on September 12, 2026


