React Native Authentication Flow a Practical Guide

Build a secure React Native authentication flow with Expo, Supabase, and secure token storage. Covers PKCE, biometrics, and common pitfalls.

Profile photo of DaminiDamini
2nd Sep 2026
Featured image for React Native Authentication Flow a Practical Guide

You've probably seen this happen: the login screen works perfectly on your network, then a user opens the app from a hotel Wi-Fi connection and the session never completes. A magic link arrives after the callback has expired, an app restart restores a stale token, or two requests refresh the same session at once and one of them logs the user out.

A production React Native authentication flow isn't a screen with an email field and a button. It crosses Expo configuration, navigation state, secure storage, browser redirects, OS-level biometric prompts, token lifecycles, and backend verification. Supabase can handle much of the identity infrastructure, but it can't decide whether your app should accept an untrusted callback or recover gracefully when a user changes devices.

That's why authentication deserves an architecture before it gets a UI. The practical patterns below focus on the failure paths that polished tutorials usually leave out.

Table of Contents

Why Most React Native Login Flows Break in the Real World

The first production bug often appears after the happy path has already passed review. A developer signs in, closes the app, opens it again, and sees the authenticated navigation stack. Then the access token expires while the app is suspended. A protected request fires during the cold start, the client treats the failure as a logged-out state, and the user lands on the sign-in screen even though a valid refresh session may still exist.

Deep links create another class of failure. OAuth and magic-link callbacks don't arrive as trusted navigation instructions. They arrive as URLs delivered by the operating system, sometimes when the app is cold, sometimes while another screen is active, and sometimes after the original authentication attempt is no longer relevant. Treating that URL as an ordinary string and immediately navigating is convenient in development, but unsafe and fragile in a shipped app.

The hidden state behind one login screen

A real flow has several independent state machines:

  • Session state: The app may be bootstrapping, authenticated, refreshing, expired, or signed out.
  • Navigation state: The router needs to know whether a protected route was requested before login and whether that request has already been consumed.
  • Storage state: Secure storage can be empty, unavailable for a short period, or out of sync with in-memory state.
  • OS authentication state: A biometric set can change when a user replaces an enrolled fingerprint or Face ID configuration.
  • Network state: A request can fail because the device is offline, the backend is unavailable, or the callback browser cannot reach the configured host.

Practical rule: A sign-in button should initiate authentication. It should never be responsible for owning the entire authentication lifecycle.

The broader trade-offs between passwords, magic links, social login, passkeys, and biometrics are worth comparing before choosing a product direction. This overview of best authentication methods for SaaS is useful for evaluating the user experience and operational cost of each method, but a mobile implementation still needs its own recovery and callback design.

The backend must verify credentials, authorization codes, redirect state, and token claims independently of the client. React Native code can improve the flow, but it can't turn an untrusted device into a trusted authority.

The Building Blocks of a Modern React Native Auth Architecture

Start with boundaries, not components. Each part of the architecture should own one responsibility and prevent a specific failure.

The session and navigation layer

An AuthProvider context should expose the current Supabase session, a bootstrap status, and actions such as sign-in, sign-out, and refresh. It gives screens a consistent source of truth. Without it, every screen tends to load tokens, interpret expiry, and make its own redirect decision.

Expo Router's public and protected route groups can then consume that state. While the provider restores the session, render a loading boundary rather than briefly showing the wrong stack. Once restoration finishes, render authenticated routes only when a verified session exists.

The auth state listener belongs near the provider, not inside a login screen. Supabase emits changes such as sign-in, sign-out, and token refresh. A single subscription prevents multiple screens from registering competing listeners and producing duplicate navigation events.

The request and persistence layer

A secure HTTP client should attach the current access token to API requests and coordinate responses that indicate an expired session. It belongs in a shared module because a refresh interceptor inside an individual screen can't protect requests initiated by another screen, a background task, or a data-fetching library.

Store refresh credentials with expo-secure-store, while keeping short-lived UI state in memory. The client should restore the persisted session during bootstrap, then let Supabase maintain the active session. Don't place refresh tokens in ordinary key-value storage just because it makes debugging easier.

A biometric gate is a separate convenience layer. It should provide access to an existing session or credential held in secure storage, not become the only way to recover the account.

A reference shape

The resulting architecture is straightforward:

  • Supabase: Authenticates users and issues sessions.
  • AuthProvider: Exposes session state to Expo Router and screens.
  • Secure storage: Persists sensitive session material.
  • HTTP client: Attaches tokens and serializes refresh work.
  • Deep-link handler: Validates and consumes OAuth or magic-link callbacks.
  • Biometric module: Adds local convenience without replacing account recovery.

A diagram illustrating a modern React Native authentication architecture using Supabase, context providers, and secure token storage.A diagram illustrating a modern React Native authentication architecture using Supabase, context providers, and secure token storage.

The important design choice is centralization. Screens collect intent and display status. The provider, client, storage adapter, and callback handler own the state transitions that make the flow dependable.

Wiring Up Supabase Auth in an Expo Project

An Expo app needs native URL configuration before OAuth or magic links can return to it. Start with the client packages:

npx expo install @supabase/supabase-js expo-secure-store expo-linking expo-auth-session expo-constants

Keep the Supabase URL and public anon key in environment configuration. The anon key is designed for client use, but it isn't a substitute for Row Level Security or server-side authorization. Never bundle service-role credentials in an Expo application.

A typed client can use a custom storage adapter:

import 'react-native-url-polyfill/auto'
import { createClient } from '@supabase/supabase-js'
import * as SecureStore from 'expo-secure-store'

const storage = {
  getItem: (key: string) => SecureStore.getItemAsync(key),
  setItem: (key: string, value: string) => SecureStore.setItemAsync(key, value),
  removeItem: (key: string) => SecureStore.deleteItemAsync(key),
}

export const supabase = createClient(
  process.env.EXPO_PUBLIC_SUPABASE_URL!,
  process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY!,
  {
    auth: {
      storage,
      autoRefreshToken: true,
      persistSession: true,
      detectSessionInUrl: false,
      flowType: 'pkce',
    },
  },
)

For production builds, register a dedicated scheme and platform identifiers in app.json:

{
  "expo": {
    "scheme": "myapp",
    "ios": {
      "bundleIdentifier": "com.example.myapp",
      "infoPlist": {
        "CFBundleURLTypes": [
          {
            "CFBundleURLSchemes": ["myapp"]
          }
        ]
      }
    },
    "android": {
      "package": "com.example.myapp",
      "intentFilters": [
        {
          "action": "VIEW",
          "autoVerify": true,
          "data": [
            {
              "scheme": "https",
              "host": "auth.example.com",
              "pathPrefix": "/auth/callback"
            }
          ],
          "category": ["BROWSABLE", "DEFAULT"]
        }
      ]
    }
  }
}

The exact scheme and host must match the redirect values configured in Supabase. Enable PKCE, set the project Site URL, and whitelist only the development and production callback URLs you use. A mismatch here commonly looks like a login failure even though the provider authenticated the user successfully.

Screenshot from https://placehold.co/1200x800/png?text=Expo+app.json+auth+configScreenshot from https://placehold.co/1200x800/png?text=Expo+app.json+auth+config

Handling the callback

For OAuth, create the authorization URL with Supabase, open it in an in-app browser, and listen for the resulting URL. Parse the callback with Linking.parse, verify the expected path and parameters, then exchange the authorization code:

const { data, error } = await supabase.auth.exchangeCodeForSession(code)

A callback route in Expo Router can perform the same exchange when your redirect target maps to a route. The route should show a short loading state, reject malformed parameters, and replace the callback screen after the exchange succeeds. It shouldn't push a new screen onto the stack, because users can return to the callback with the back button.

For implementation details around email, OAuth, and Apple sign-in, keep this complete Supabase Auth guide for React Native nearby while matching its configuration to your own bundle identifiers and redirect scheme.

Storing Tokens Safely on a Mobile Device

Token storage is a security decision, not a convenience setting. The refresh token has a longer useful lifetime than an access token, so exposing it in an easily inspectable store gives an attacker a greater advantage than exposing a temporary UI preference.

StorageEncryptionPersistenceBest Use
AsyncStorageNot intended for sensitive secretsPersists across normal app restartsTheme, onboarding state, non-sensitive flags
SecureStoreUses native secure storage facilitiesDesigned for sensitive app dataRefresh tokens and session material
MMKVFast local key-value storage, security depends on configurationPersists across normal app restartsHigh-speed non-secret state, or carefully secured data with an explicit threat model
Keychain in bare React NativeNative iOS secure storagePersists according to accessibility settingsDirect control over iOS credential storage

AsyncStorage is acceptable for a flag such as “has completed onboarding.” It shouldn't hold a refresh token merely because the API is simple. MMKV can be attractive for performance, but speed doesn't automatically provide the protections your session credentials require.

A small SecureStore adapter

Supabase can use a storage interface, so the security boundary stays in one module:

import * as SecureStore from 'expo-secure-store'

export const authStorage = {
  getItem: (key: string) => SecureStore.getItemAsync(key),
  setItem: (key: string, value: string) =>
    SecureStore.setItemAsync(key, value, {
      keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
    }),
  removeItem: (key: string) => SecureStore.deleteItemAsync(key),
}

The accessibility choice matters. WHEN_UNLOCKED_THIS_DEVICE_ONLY keeps the item available only after the device is unlocked and avoids treating it as transferable backup data. Choose a different policy only when your recovery and threat model justify it.

On sign-out, clear the server session and local credentials together:

await supabase.auth.signOut()
await SecureStore.deleteItemAsync('biometric_unlocked')

Supabase's storage adapter will remove its session keys during sign-out, while app-specific flags need explicit cleanup. On an OS upgrade, a first SecureStore read can occasionally return null or fail transiently. Treat that as a bootstrap state, retry carefully, and show the password or recovery path instead of assuming the account disappeared.

Adding Biometrics Without Locking Anyone Out

Biometrics should shorten a trusted user's path into the app, while Supabase authentication, account recovery, and password access remain available. Expo's expo-local-authentication supplies the native prompt for Face ID, Touch ID, and Android biometric authentication, but the prompt itself does not define a safe product flow.

The first step is to check whether the device supports biometrics and whether the user has enrolled one:

import * as LocalAuthentication from 'expo-local-authentication'

const supported = await LocalAuthentication.hasHardwareAsync()
const enrolled = await LocalAuthentication.isEnrolledAsync()

if (supported && enrolled) {
  const result = await LocalAuthentication.authenticateAsync({
    promptMessage: 'Authenticate to continue',
    disableDeviceFallback: false,
  })
}

A successful result can permit access to protected session material or pass the app's local session gate. A failure must preserve the password path. Fingerprints can become unreadable, biometric enrollment can change, a child may be using a parent's device, or the operating system may reject the prompt for reasons the app cannot control.

Make fallback visible

Show “Use password instead” directly on the biometric screen rather than waiting for a cryptic error. After repeated failures, stop prompting and offer the password or recovery route instead of trapping the user in an OS dialog loop.

A separate biometric_verified flag can live in SecureStore, but it is only an app state marker, not proof of identity. Bind its validity to the current session or refresh-token lifecycle, clear it on sign-out, and invalidate it when the stored session cannot be restored. This keeps biometric access tied to a session the server still recognizes.

Step-up authentication should protect sensitive actions such as changing payment details, exporting private data, or replacing an account email. The prompt remains independent of how the user opened the app, and the server must authorize the action.

For a broader explanation of the security and UX model, see this guide to what biometric authentication means in mobile apps.

A flowchart showing five steps for implementing biometric authentication as a convenience layer in mobile applications.A flowchart showing five steps for implementing biometric authentication as a convenience layer in mobile applications.

Recent UX guidance makes the same practical point: biometric login needs an accessible alternate method, and recovery should be planned early because users can lose access to a phone or email account. Passkeys and step-up verification also make a forced biometric prompt a poor default for every session. See current mobile authentication UX guidance for the broader fallback and recovery perspective.

Deep Links, Token Refresh, and the Pitfalls Nobody Talks About

A deep link is a security boundary. The operating system delivers a URL to your app, but that doesn't mean every parameter in the URL is legitimate or belongs to the authentication request the user started.

Before opening the browser, generate a random state value and keep it in memory or a short-lived secure record. Include it in the request, then compare the returned value before accepting the callback. With PKCE, retain the verifier until the exchange completes. Reject unexpected schemes, hosts, paths, missing parameters, and callbacks that arrive after the request's short validity window.

Don't accept arbitrary redirect targets from query parameters. Whitelist your scheme and verified Universal Link domain, then route only to known callback screens. This deep-linking guide for React Native is useful for the navigation mechanics, but the security validation still belongs in your authentication layer.

Serialize refresh work

The classic race looks like this:

  1. Screen A receives an unauthorized response.
  2. Screen B receives an unauthorized response moments later.
  3. Both call refreshSession.
  4. One refresh rotates or invalidates the token used by the other.
  5. One request succeeds while the other clears local state or reports a false logout.

Use a singleton promise so every caller waits for the same refresh operation:

let refreshPromise: Promise<Session | null> | null = null

export function refreshOnce() {
  if (!refreshPromise) {
    refreshPromise = supabase.auth
      .refreshSession()
      .then(({ data, error }) => {
        if (error) throw error
        return data.session
      })
      .finally(() => {
        refreshPromise = null
      })
  }

  return refreshPromise
}

Your HTTP client can await refreshOnce() after a single unauthorized response, update the token, and retry the original request once. Never retry indefinitely. If refresh fails, clear the session through one centralized sign-out path.

A checklist infographic detailing five essential security best practices for implementing deep links and token refresh in applications.A checklist infographic detailing five essential security best practices for implementing deep links and token refresh in applications.

Logout needs the same discipline. Clear the Supabase session, in-memory provider state, Zustand stores, React Query caches, pending deep-link intent, and biometric marker as one logical operation. If you clear only the navigation stack, a later screen can still render data from the previous account.

Test the cases developers skip: cold-start callback delivery, duplicate callback delivery, malformed state, an expired code, two simultaneous unauthorized requests, offline bootstrap, a changed biometric enrollment, sign-out during a refresh, and login after a protected deep link. These tests expose architecture problems far earlier than another successful email-and-password run.

Your Auth Launch Checklist and What to Build Next

A launch checklist should fit on one screen because its job is verification, not anxiety. Run it against a development build and a production-like build, not only inside Expo Go.

Environment and configuration

  • Register the app identity: Confirm the custom scheme, iOS bundle identifier, Android package, and platform URL configuration match the installed build.
  • Whitelist callbacks: Check Supabase Site URL and additional redirect URLs for each environment. Remove temporary callback targets before release.
  • Protect credentials: Keep public client configuration separate from server secrets. Service-role keys and backend signing secrets must never enter the Expo bundle.
  • Separate builds: Use distinct EAS profiles and environment values for development, staging, and production so a test callback can't reach a live account system.

Runtime behavior

  • Restore before rendering: Hydrate the Supabase session from SecureStore before deciding which route group to display.
  • Refresh once: Make every request await the same in-flight refresh operation, then retry only the original request.
  • Validate callbacks: Check the scheme, host, path, state, PKCE exchange, and callback freshness before changing session state.
  • Preserve fallback: A broken biometric sensor, changed enrollment, or declined prompt must return the user to password or recovery, not a dead end.

Resilience

  • Exercise recovery: Test lost-device and lost-email scenarios, expired magic links, password reset links, and interrupted browser sessions.
  • Clear everything on logout: Remove server session state, secure credentials, local stores, cached queries, and pending navigation intent.
  • Show stale offline state: If the app can display cached content without a fresh session check, label that state clearly and prevent sensitive mutations until authorization is confirmed.

Once those checks pass, the next layer can include server-side session revocation, anomaly detection for rapid geographic sign-ins, and a backend-for-frontend when client authorization logic starts spreading across too many services. A starter kit such as AppLighter can provide preconfigured Expo authentication, session management, protected routes, Supabase integration, and related app infrastructure, but you should still verify its defaults against your own threat model and recovery requirements.


AppLighter gives you an Expo and React Native starter foundation with authentication, session management, navigation, protected routes, and Supabase-backed app infrastructure already wired together. Visit AppLighter to start from a working mobile architecture, then spend your time hardening the callback, refresh, biometric fallback, and recovery paths that make authentication reliable in production.

Stay Updated on the Latest UI Templates and Features

Be the first to know about new React Native UI templates and kits, features, special promotions and exclusive offers by joining our newsletter.