Skip to content
shipanysaas

Expo Supabase setup: one backend for your web and phone apps

Expo Supabase setup for sign-in: an encrypted session on the phone, token refresh tied to AppState, auth deep links, and one API shared with Next.js.

An Expo Supabase setup runs supabase-js inside your Expo (React Native) app against the same Supabase project as your web app: one set of users, one Postgres database, one file store. To get sign-in right, store the session securely on the phone, refresh tokens only while the app is open, and route auth redirects back with deep links.

Someone who signs up on the web then signs in on the phone with the same account and sees the same teams. What differs is everything around the client, and this post covers each piece with the choices shipanysaas makes in its phone app.

What is Supabase Expo?

Not a separate product. It is the regular @supabase/supabase-js client running in an Expo app, set up the way Supabase's Expo React Native quickstart describes. Auth, the database, Storage and Edge Functions all work from the phone with the same project URL and publishable key your web app uses.

That key is meant to ship. Supabase's API keys docs call the publishable key safe to expose in a web page or mobile app, because row-level security decides what it can reach. That matters, because nothing in a phone app stays private: Expo inlines every EXPO_PUBLIC_ variable into the bundle. The secret key is different. It bypasses row-level security, so it never goes into an app build.

Expo sign-in session setup

As of October 2026, the quickstart installs @supabase/supabase-js, react-native-url-polyfill and expo-sqlite, reads EXPO_PUBLIC_SUPABASE_URL and EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY, and creates the client with four auth options:

  • storage: where the session lives. The quickstart uses the localStorage polyfill from expo-sqlite.
  • persistSession: true: the session survives the app being closed.
  • autoRefreshToken: true: supabase-js refreshes the access token before it expires.
  • detectSessionInUrl: false: there is no address bar to read a session from.

Three questions decide whether sign-in holds up in production.

How is the session stored on the phone?

Encrypted, in the kit. A session holds a refresh token, which can mint new access tokens, so it deserves better than plain storage. expo-secure-store keeps values in the iOS Keychain and, on Android, in storage encrypted with the Keystore. But Expo's SecureStore docs note that some iOS releases have refused values above roughly 2,048 bytes, and a session can be larger than that.

Supabase's Expo user-management tutorial gets around this by splitting the session: a random 256-bit AES key in SecureStore, and the session encrypted with it in AsyncStorage. The kit does the same with a LargeSecureStore class in apps/native/features/core/supabase.ts:

export const supabase = createClient<Database>(
  resolveDevHost(supabaseUrl),
  supabasePublicKey,
  {
    auth: {
      ...(Platform.OS !== 'web' && { storage: new LargeSecureStore() }),
      autoRefreshToken: true,
      persistSession: true,
      detectSessionInUrl: false,
      flowType: 'pkce',
    },
  },
);

LargeSecureStore makes a fresh AES-256 key on every write. If the two halves fall out of sync, after a restored backup for example, it deletes both and the user signs in again, instead of failing on every launch. SecureStore has no web version, so the app's web build falls back to the browser storage supabase-js uses by default. The kit names its variables EXPO_PUBLIC_SUPABASE_URL and EXPO_PUBLIC_SUPABASE_PUBLIC_KEY.

How does the token stay fresh?

autoRefreshToken does the refreshing, and you start and stop it with the app's foreground state. The supabase-js reference explains why: on non-browser platforms the refresh process runs continuously in the background, which may not be what you want, so hook into the platform's foreground signal. In React Native that is AppState:

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

Register it once. The kit does this in apps/native/features/auth/hooks/use-auth-state.ts, which also starts the refresh if the app is already active when the hook mounts, and stops it on unmount.

How do auth redirects get back into the app?

Through a custom URL scheme. Password sign-in needs no redirect; OAuth does. After Google, Supabase sends the user back to a URL, and on a phone that URL has to open your app. Supabase's deep linking guide sets it up in four steps:

  1. Register a scheme in your Expo app config.
  2. Add <scheme>://** to the redirect URLs in your Supabase project's Auth settings.
  3. Build the redirect with makeRedirectUri from expo-auth-session, and call signInWithOAuth with skipBrowserRedirect: true.
  4. Open the returned URL with WebBrowser.openAuthSessionAsync, then create the session from the URL that comes back.

Supabase's example reads the tokens straight from that URL with setSession. The kit uses the PKCE flow instead (flowType: 'pkce'): the browser hands back a one-time code, and exchangeCodeForSession swaps it, with a verifier the client stored when the flow began, for a session. Supabase's PKCE docs say the code is valid for five minutes and works once.

The kit's scheme is shipanysaas, set in apps/native/app.json, and apps/native/features/auth/lib/oauth.ts builds the redirect shipanysaas://auth/callback, so shipanysaas://** goes on the redirect list. Rename the scheme and that entry together. If you add Apple to the providers, iOS uses the native Apple sheet and signInWithIdToken, which needs no redirect at all. OAuth providers lists the kit's redirect URLs for local and production.

Email flows can skip deep links altogether. Supabase's email templates can include a six-digit code ({{ .Token }}) to use instead of the link, and the app checks it with verifyOtp. The kit's phone app confirms sign-ups and password resets with typed codes, and accepts a team invitation inside the app by matching it to the user's verified email.

Expo Router and Supabase auth: guarding screens

Expo Router's authentication guide uses protected routes: wrap screens in Stack.Protected with a guard prop, and while the guard is false those screens can't be reached. A layout can also return a <Redirect>.

The kit's layouts use redirects. One hook turns onAuthStateChange into a single status (loading, signed out, needs a second factor, signed in) plus a flag for password recovery. apps/native/app/(home)/_layout.tsx sends each case to sign-in, the two-step challenge or the new-password screen before the app shell renders.

A guard is only navigation. It decides which screens show, not which rows a request can read. That is row-level security's job, and it is why two apps can share one database safely.

One API for both apps

The phone app can reach your data in two ways:

  1. Query Supabase directly with supabase-js. Each request carries the user's access token, so row-level security applies, as it does in the browser.
  2. Call your web app's API with the access token as a bearer token. The route builds a Supabase client from that token, so row-level security still applies, and anything that needs server code runs in one place for both apps.

The kit uses both. The phone app talks to Supabase directly for sign-in, two-step codes and file uploads, and goes through the web app's /api/v1 routes for account and team data. apps/native/features/core/api-client.ts attaches the token:

const { data } = await supabase.auth.getSession();
const token = data.session?.access_token;

if (token) {
  headers.set('Authorization', `Bearer ${token}`);
}

On the web side, enhanceRouteHandler from @repo/next/routes sees the header, builds a client from the token and verifies it with getClaims. When the token is invalid, or the user still owes a second factor, it answers 401 with unauthenticated or mfa-required, because an app can't follow a redirect to a sign-in page. Without the header, the same route reads the browser's cookie session, so one route serves both apps.

Why add an API when row-level security already protects the rows? Because some actions are more than a row write. Inviting a member checks role rank and sends an email. Accepting an invitation can change the seat count on a per-seat Stripe subscription. Those need server code and secret keys, which must never ship inside an app. The routes are versioned too: the kit's rule is that a breaking change goes to /api/v2, so phones still running an older build keep working.

What stays on the web

Checkout, the billing portal and the admin panel stay on the web in the kit. On the phone, a team's plan shows as a read-only badge. Selling digital goods inside an iOS or Android app falls under each store's own payment rules, and those purchase systems share no code with the kit's Stripe or Lemon Squeezy integration, so the kit leaves that decision to you.

How the kit's phone app is put together

If you would rather start from an Expo Supabase starter than wire all this up yourself, this is what the All-in plan adds to the web app. apps/native is an Expo SDK 57 app with Expo Router, built for iPhone, iPad and Android from the same repository as the Next.js app. Its components come from packages/mobile-ui, React Native Reusables primitives styled with Uniwind. It shares sign-in, personal and team accounts, roles, members and invitations with the web app, and its strings come from the web app's message files.

If you build with a coding agent, apps/native/AGENTS.md maps the app, and Expo's own skills (expo-router, expo-upgrade, expo-dev-client, eas-update, eas-app-stores) ship with it in All-in. For choosing a mobile stack in the first place, see React Native vs Flutter.

The phone demo is the same Expo app built for the web and connected to the web demo's backend, so its shared login and sample team are the web demo's. Plans are on the pricing page, and Project structure shows where /api/v1 sits.

FAQ

Do web and phone users share one account?

Yes. Both apps sign in against the same Supabase Auth users. In the kit, a personal account's id is the user's id, so teams, roles and data are the same on both.

Is AsyncStorage safe for Supabase sessions?

Not on its own: AsyncStorage is unencrypted. Use it only for the encrypted half, with the key in SecureStore, as Supabase's tutorial and the kit's LargeSecureStore do.

Not for sign-in. OAuth comes back through the custom scheme, and every email flow in the kit's phone app uses a typed code, so the kit ships without Universal Links.

Can I use the phone app without the web app?

Not as shipped. The phone app reaches the database through the web app's /api/v1 routes, so deploy the web app first and point EXPO_PUBLIC_API_BASE_URL at it.