# Supabase Auth sessions in Expo stay signed in when storage and refresh are wired right

> Keep users signed in after launch: storage, token refresh, sign-out, and OAuth redirects in Expo.
> By Dave · 2026-09-05
> Source: https://otf-kit.dev/blog/supabase-auth-expo-session-production

Authentication is the feature that works perfectly in development and breaks the week after launch. Sign-in succeeds on your simulator, the session survives hot reloads, and everything looks finished. Then real users open the production build, get logged out overnight, find themselves stuck on a splash screen after an app restart, or discover that one OAuth provider works on iOS and silently fails on Android. Almost every one of these failures traces back to session handling: where tokens are stored, how they refresh, and what happens when they expire. This post walks through production-ready Supabase Auth session handling in an Expo app, following the official Expo quickstart and adding the launch discipline that keeps users signed in.

## Why sessions die in production builds

During development, your JavaScript runtime stays alive across hot reloads, so the in-memory session never gets tested the way production tests it. In production, the OS kills backgrounded apps to reclaim memory, users restart their phones, and access tokens expire because they are short-lived by design. Every one of those events forces the app to restore the session from persistent storage and, when the access token has expired, to exchange the refresh token for a new one without bothering the user.

If any link in that chain is misconfigured, the symptom is always the same from the user's perspective: the app forgot who I am. The fix is never to make tokens live longer. The fix is to wire storage, refresh, and expiry handling correctly, then verify each path before launch. The [production shipping checklist](/blog/ship-ai-mvp-to-production-checklist) makes the same point at a higher level: the gaps that hurt are the ones between "works on my machine" and "works after an OS-level app kill."

## Initialize the client with explicit auth options

The official Supabase guide for Expo React Native documents the full client setup. After scaffolding with the blank TypeScript template and installing the client library with its session dependencies:

```bash
npx create-expo-app my-app --template blank-typescript
```

```bash
cd my-app && npx expo install @supabase/supabase-js react-native-url-polyfill expo-sqlite
```

You declare the connection values in a local env file using Expo's public-variable prefix, then initialize the client in a helper module:

```ts
import 'react-native-url-polyfill/auto'
import { createClient } from '@supabase/supabase-js'
import 'expo-sqlite/localStorage/install'

const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL!
const supabasePublishableKey = process.env.EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY!

export const supabase = createClient(supabaseUrl, supabasePublishableKey, {
  auth: {
    storage: localStorage,
    autoRefreshToken: true,
    persistSession: true,
    detectSessionInUrl: false,
  },
})
```

Every option in the auth block earns its place. The `storage` entry points at the Expo localStorage polyfill so sessions survive app restarts instead of living only in memory. Setting `autoRefreshToken` to true lets the client silently renew expired access tokens in the background, which is what makes "stays signed in" actually true. Setting `persistSession` to true writes the session to storage on every change. And `detectSessionInUrl` is false because a native app has no browser URL to detect a session in; leaving the default on is a web assumption leaking into a mobile build.

Two production notes on this setup. First, keep the publishable key in the app and the secret keys on your backend only. The client library is designed to operate with the publishable key plus row-level security enforcing the boundaries. Second, never hardcode the project URL and key in source. Env vars with the Expo public prefix are readable in app code by design, which is exactly what you want for values that ship inside the binary, but they should still come from your build environment per channel so preview builds and production builds point at different projects.

## Store sessions where the platform protects them

The quickstart's storage choice, the Expo SQLite-backed localStorage polyfill, persists sessions across restarts and works reliably on both platforms. That is the correct default for most consumer apps, and you should adopt it before reaching for anything more exotic.

For apps handling sensitive data, consider going one step further with an adapter backed by the OS keychain and keystore, which encrypts stored values at rest with hardware support where available. The trade-off is real: keychain-backed storage can behave differently across OS upgrades, device migrations, and backup restores, so it adds test surface. Our guidance is simple. Ship the documented default first, verify the session paths below, and only move tokens into hardware-backed storage when your threat model specifically requires it. Either way, the decision belongs in your launch notes next to the rest of your data-handling choices, not discovered during a security review after release.

## Put row-level security behind every authenticated query

A persisted session is only half of auth. The other half is what that session is allowed to touch. Every table your app reads or writes must have row-level security enabled with policies that scope access to the authenticated user, typically by comparing the row owner against the request's auth identity. The official Expo guide itself demonstrates this pattern: create the table, grant only the privileges the role needs, enable row-level security, and add a policy before the app ever queries it.

If you have not done this yet, stop and read the [row-level security production checklist](/blog/supabase-rls-production-checklist) first, because it covers testing every policy path before launch. The failure mode here is silent and severe: without policies, any authenticated session can read any row, and no amount of correct session handling on the client fixes a missing server-side boundary. Authenticate the user on the device, authorize every row on the server.

## Handle sign-in, sign-out, and token refresh as explicit states

Production auth bugs cluster around transitions, not steady states. Sign-in works, staying signed in works, and then sign-out leaves half the session behind, or a refresh failure leaves the UI in a limbo that is neither logged in nor logged out. Model auth as three explicit states with transitions you can test: signed out, signed in with a valid session, and signed in with an expired session that must refresh.

Subscribe to auth changes once at the root of your app so every transition flows through one handler:

```ts
const { data: listener } = supabase.auth.onAuthStateChange(
  (event, session) => {
    if (event === 'SIGNED_IN') {
      setUser(session?.user ?? null)
    }
    if (event === 'SIGNED_OUT') {
      setUser(null)
    }
    if (event === 'TOKEN_REFRESHED') {
      setUser(session?.user ?? null)
    }
  }
)
```

Unsubscribe when the root unmounts. Then test each transition deliberately: fresh install sign-in, app kill and relaunch while signed in, sign-out followed by relaunch to confirm nothing lingers, and sign-in on one device followed by sign-in on a second device if your product allows multi-device sessions. The sign-out path deserves special attention because leftover tokens in storage are how "I logged out on my old phone" becomes a support ticket. Confirm that sign-out clears the persisted session and that a relaunch afterwards lands on the signed-out state, not on a zombie session.

Push notification wiring is one place these transitions bite in practice. Device push tokens are typically registered against the user identity, so a stale session means notifications routed to the wrong account or to nobody. Keep the token registration step inside the signed-in transition, clear it on sign-out, and run through the [Expo push notification production checklist](/blog/expo-push-notifications-production) with auth transitions in mind.

## Register redirect URLs before enabling OAuth providers

Password sign-in has no redirect step, which is why OAuth breaks in builds where password auth works fine. Every OAuth provider hands control back to your app through a redirect URL, and two things must agree for that handoff to land: the URL allow list in your project's auth settings must contain your app's redirect, and the app build must actually handle the incoming deep link for its own scheme.

Set this up per build channel. Development, preview, and production builds use different schemes or hosts, and a redirect registered only for development produces the classic symptom of OAuth working on your machine and failing everywhere else. Test the full provider round trip on a real device in a release-channel build, not just in the simulator, because deep-link handling is one of the behaviors most likely to differ between the two. Add one row per provider per channel to your pre-launch sheet and check each box with a real tap, a real account, and a real redirect back into the app.

## Verify auth before you ship

Run this list against a release-channel build on a physical device before every submission:

- Fresh install: sign up, confirm the session persists across an OS-level app kill and relaunch.
- Overnight test: leave the app signed in for a full day, then reopen and confirm silent token refresh with no login screen.
- Sign-out: sign out, kill the app, relaunch, and confirm the signed-out state with no residual session.
- Every OAuth provider on both platforms, from a release build, through the real redirect.
- Authenticated queries against tables with row-level security enabled, confirming users see only their own rows.
- Preview and production builds pointed at their respective projects, with redirects registered for each.

Authentication is infrastructure your users feel but never see. When session handling is wired correctly, nobody thinks about it at all, which is exactly the goal.

## Sources

- Supabase official docs, Expo React Native quickstart (project setup, client install, env vars, client initialization with auth storage and refresh options), verified September 2026: [Use Supabase with Expo React Native](https://supabase.com/docs/guides/getting-started/quickstarts/expo-react-native)