Skip to main content
Articles

How to Handle Session Expiry in a React Native App with Clerk

Author: Roy Anger
Published:

Mobile apps live in a harsher environment than web applications. Users background your app while commuting, lose network connectivity in elevators, and return after hours or days expecting everything to work. When a session expires during any of these scenarios, a poor implementation means lost form data, confusing error screens, or users trapped on broken views.

Session expiry is not a bug — it is a security feature. The challenge is handling it so well that users barely notice. This article explains how Clerk manages sessions in Expo apps, from automatic token refresh to offline resilience, and walks you through building a production-ready session management flow.

What you'll learn

  • How Clerk's two-token architecture works in React Native
  • How to configure session lifetimes and inactivity timeouts
  • How to detect session expiry and respond with the correct UX
  • How to handle background/foreground transitions and offline scenarios
  • How to protect in-progress API calls from mid-transaction expiry
  • How to use native OAuth to reduce session friction
  • How to test session expiry scenarios during development

Note

This article targets Expo apps (both Expo Go and development builds) using Clerk Core 3 (@clerk/expo 3.x) with Expo SDK 53+. Some features, such as native OAuth, require a development build and will not work in Expo Go. These are noted where relevant.


How Clerk sessions work in React Native

Understanding Clerk's session model is the foundation for handling expiry correctly. Clerk uses a hybrid stateful/stateless architecture that separates long-lived identity from short-lived authorization.

The two-token architecture

Clerk uses two tokens per session, as described in the How Clerk works guide:

  • Client token: A long-lived token that serves as the source of truth for authentication state. It contains a unique client identifier and a rotating anti-session-fixation token. Its expiration defines the overall session lifetime. In the web SDK, this is stored as an HTTP-only cookie (__client) on the FAPI domain. In Expo, tokenCache with expo-secure-store provides persistent encrypted storage for Clerk's authentication credentials, replacing cookie-based storage.
  • Session token (JWT): A short-lived JSON Web Token with a 60-second lifetime. It contains claims like sub (user ID), sid (session ID), exp (expiration), iat (issued at), and fva (factor verification age). Session tokens are used for API authorization — your backend verifies them without calling Clerk's servers.

The SDK generates new session tokens by calling POST /client/sessions/<id>/tokens using the client token. This separation means a compromised session token expires in 60 seconds, while the client token can be rotated independently.

This two-token model limits the blast radius of a leaked JWT to a 60-second window, compared to single-token approaches where a stolen credential grants full access until it expires or is revoked.

Automatic token refresh

Clerk's SDK refreshes session tokens on a recurring interval, approximately matching the 60-second token lifetime. This happens automatically — no developer code is required.

In Core 3, getToken() uses a stale-while-revalidate strategy. When a token is within 15 seconds of expiry, getToken() returns the cached token immediately and triggers a background refresh. In Core 2, getToken() blocked until the refresh completed. This change means your app never waits for a token refresh during normal operation.

Session states

Every Clerk session has one of eight statuses. Each status triggers different behavior in your app:

StatusTriggerDeveloper action
activeSession is current and validNormal operation
pendingUser authenticated but has incomplete tasks (org selection, MFA setup, password reset)Show task completion UI
endedsession.end() called client-sideRedirect to sign-in
expiredExceeded maximum session lifetimeRedirect to sign-in
removedsession.remove() called client-sideRedirect to sign-in
abandonedInactivity timeout triggeredRedirect to sign-in
replacedAnother session took over (multi-session apps)Handle gracefully
revokedAdmin or backend revoked session via Backend APIRedirect to sign-in

Pending sessions require special attention. Session tasks that cause a pending status include choose-organization, reset-password, and setup-mfa. By default, pending sessions are treated as signed-out in Clerk's authentication context. Your route guards must distinguish "session pending a task" from "session expired" to show the correct UI. See the Detecting session expiry section for the treatPendingAsSignedOut option.

Revoked sessions differ from ended and removed in that they are triggered server-side — an admin or backend process calling the revoke endpoint. Mobile apps should handle revoked the same way they handle expired.

Authentication states in React Native

In the Expo SDK, there are two authentication states that matter:

  • Signed-in: isSignedIn === true. A valid active session exists.
  • Signed-out: isSignedIn === false. No active session.

The web-only "handshake" state does not apply to React Native. The Expo SDK uses tokenCache for session bootstrapping instead of HTTP cookie handshakes. Do not implement handshake handling in your Expo app.


Setting up Clerk in an Expo app

This section covers the essential session-related configuration. For the full setup, see the Expo quickstart.

Installing dependencies

Install the core packages:

npx expo install @clerk/expo expo-secure-store

Important

The package was renamed from @clerk/clerk-expo to @clerk/expo in Core 3. If you are migrating from Core 2, run bunx @clerk/upgrade for automated migration.

If you plan to use browser-based OAuth via useSSO(), also install:

npx expo install expo-web-browser expo-auth-session

These are not required for native OAuth or for session expiry handling.

Configuring ClerkProvider with token caching

The ClerkProvider wraps your app and manages authentication state. The tokenCache prop is essential — without it, tokens are stored in memory only and lost when the app restarts.

import { ClerkProvider } from '@clerk/expo'
import { tokenCache } from '@clerk/expo/token-cache'

export default function RootLayout() {
  return (
    <ClerkProvider
      publishableKey={process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!}
      tokenCache={tokenCache}
    >
      <Slot />
    </ClerkProvider>
  )
}

Key props:

  • publishableKey (required): Your Clerk publishable key. Must be passed explicitly in Core 3 — environment variables inside node_modules are not inlined in production builds.
  • tokenCache: Persists tokens to expo-secure-store. Always enable this in production.
  • touchSession (default true): Clerk documents this prop as calling the Frontend API touch endpoint during "page focus." Because page focus is a browser concept relying on window.focus and document.visibilityState, touchSession may not behave as expected in Expo apps. In practice, an AppState-based pattern is more reliable for mobile keep-alive (see Handling app state transitions).

Note

The @clerk/expo ClerkProvider automatically sets standardBrowser={!isNative()} internally. You do not need to set standardBrowser manually in your code.

Enabling offline support (experimental)

Clerk provides an experimental resource cache that enables the SDK to bootstrap without network access and return cached tokens when offline.

import { ClerkProvider } from '@clerk/expo'
import { tokenCache } from '@clerk/expo/token-cache'
import { resourceCache } from '@clerk/expo/resource-cache'

export default function RootLayout() {
  return (
    <ClerkProvider
      publishableKey={process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!}
      tokenCache={tokenCache}
      __experimental_resourceCache={resourceCache}
    >
      <Slot />
    </ClerkProvider>
  )
}

Benefits of resourceCache:

  • Faster isLoaded resolution — the SDK can initialize from cached data
  • Cached token return when offline
  • SDK bootstraps without a network connection

Warning

The __experimental_resourceCache API is experimental and may change. The tokenCache prop is stable and recommended for all production apps. Only resourceCache carries the experimental designation.


Configuring session lifetime and inactivity timeout

Session expiry behavior is configured in the Clerk Dashboard under Sessions > Session options.

Maximum session lifetime

The maximum lifetime defines how long a session can exist regardless of activity. When a session exceeds this limit, its status transitions to expired.

  • Default: 7 days
  • Range: 5 minutes to 10 years
  • Customization: Requires a paid plan in production. Free for development instances.

Inactivity timeout

The inactivity timeout defines how long a session can exist without token refreshes. A user is "inactive" when the app stops refreshing tokens — typically when the app is backgrounded, closed, or killed. When the timeout triggers, the session transitions to abandoned.

  • Default: Disabled
  • Constraint: At least one of maximum lifetime or inactivity timeout must be enabled
  • Customization: Requires a paid plan in production. Free for development instances.

Choosing the right configuration for mobile

Session configuration depends on your app's security requirements. Use shorter lifetimes for apps handling sensitive data:

App categoryIdle timeoutSession lifetimeReference
Banking / Financial15 minutes12 hoursPCI DSS 8.2.8 (idle); NIST SP 800-63B AAL2 (lifetime)
HealthcareOrganization-definedOrganization-definedHIPAA §164.312(a)(2)(iii) — requires automatic logoff but does not prescribe specific values
E-commerce15-30 minutes24 hoursIndustry practice, consistent with OWASP guidance
Social / Consumer30+ minutes30+ daysUX-driven, aligns with NIST SP 800-63B AAL1 (30-day reauthentication)
Internal / Enterprise15 minutes12 hoursNIST SP 800-63B AAL3 (idle); AAL2/AAL3 (lifetime)

Note

PCI DSS mandates a 15-minute idle timeout but does not specify session lifetimes. HIPAA requires automatic logoff but leaves the timeout duration to organizational risk assessment — the 5-15 minute range commonly used in healthcare apps reflects industry practice, not a regulatory mandate. Many financial institutions implement stricter timeouts (2-5 minutes) as internal policy beyond PCI DSS minimums.

For most consumer Expo apps, the 7-day default session lifetime with no inactivity timeout provides a good balance between security and user experience. Enable inactivity timeout if your app handles financial or medical data.


Detecting session expiry in your app

This section covers practical patterns for detecting when a session has expired or is about to expire.

Handling the initialization window

In Expo apps, many perceived "session expiry bugs" come from rendering protected screens before Clerk has finished initializing. Until isLoaded is true, the isSignedIn value is undefined — not false. A premature if (!isSignedIn) redirect fires even when the user has a valid cached session.

import { useAuth } from '@clerk/expo'
import { Redirect, Stack } from 'expo-router'
import { ActivityIndicator, View } from 'react-native'

export default function ProtectedLayout() {
  const { isLoaded, isSignedIn } = useAuth()

  if (!isLoaded) {
    return (
      <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
        <ActivityIndicator size="large" />
      </View>
    )
  }

  if (!isSignedIn) {
    return <Redirect href="/sign-in" />
  }

  return <Stack />
}

Always check isLoaded before isSignedIn. Never place a <Redirect> before the isLoaded guard. Never return null from a root layout — show a loading indicator instead.

Using useAuth() to monitor authentication state

The useAuth() hook provides the core authentication state:

import { useAuth } from '@clerk/expo'
import { useEffect, useRef } from 'react'
import { router } from 'expo-router'

export function SessionMonitor() {
  const { isLoaded, isSignedIn, sessionId } = useAuth()
  const wasSignedIn = useRef(isSignedIn)

  useEffect(() => {
    if (!isLoaded) return

    if (wasSignedIn.current && !isSignedIn) {
      // Session expired or was ended — redirect to sign-in
      router.replace('/sign-in')
    }

    wasSignedIn.current = isSignedIn
  }, [isLoaded, isSignedIn])

  return null
}

The treatPendingAsSignedOut option controls how pending sessions appear. By default (true), a user completing MFA setup or organization selection appears signed-out in useAuth(). Pass { treatPendingAsSignedOut: false } if your route guards need to distinguish pending tasks from actual sign-out:

const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false })
// isSignedIn is true for pending sessions — use this to show task UI instead of sign-in

Using useSession() for detailed session information

The useSession() hook exposes the full session object with timing and status details:

import { useSession } from '@clerk/expo'
import { Text, View } from 'react-native'

export function SessionHealthDisplay() {
  const { isLoaded, session } = useSession()

  if (!isLoaded || !session) {
    return null
  }

  return (
    <View>
      <Text>Status: {session.status}</Text>
      <Text>Expires: {session.expireAt?.toISOString()}</Text>
      <Text>Abandon at: {session.abandonAt?.toISOString() ?? 'No timeout'}</Text>
      <Text>Last active: {session.lastActiveAt?.toISOString()}</Text>
    </View>
  )
}

Monitoring session status changes

Use a useEffect to watch for session status transitions and trigger navigation:

import { useSession } from '@clerk/expo'
import { useEffect } from 'react'
import { router } from 'expo-router'
import { Alert } from 'react-native'

export function SessionStatusWatcher() {
  const { session } = useSession()

  useEffect(() => {
    if (!session) return

    const terminalStatuses = ['expired', 'ended', 'abandoned', 'removed', 'revoked']

    if (terminalStatuses.includes(session.status)) {
      Alert.alert('Session ended', 'Your session has expired. Please sign in again.', [
        { text: 'OK', onPress: () => router.replace('/sign-in') },
      ])
    }
  }, [session?.status])

  return null
}

Handling getToken() in Core 3

In Core 3, getToken() behavior depends on network availability and your configuration:

  • Network available, authenticated: Returns a valid session token. Uses stale-while-revalidate to refresh proactively.
  • Network unavailable, no resourceCache: Throws a runtime error with code: 'network_error'. This is the Core 3 breaking change — previously it returned null.
  • Network unavailable, resourceCache enabled: Returns a cached token instead of throwing, enabling offline-capable apps.
  • Unauthenticated: Returns null regardless of network state.

The documented pattern for Expo uses isClerkRuntimeError from @clerk/expo:

import { useAuth, isClerkRuntimeError } from '@clerk/expo'

export function useAuthenticatedFetch() {
  const { getToken } = useAuth()

  async function fetchWithAuth(url: string, options?: RequestInit) {
    try {
      const token = await getToken()

      if (!token) {
        // User is not authenticated — redirect to sign-in
        throw new Error('Not authenticated')
      }

      return fetch(url, {
        ...options,
        headers: {
          ...options?.headers,
          Authorization: `Bearer ${token}`,
        },
      })
    } catch (error) {
      if (isClerkRuntimeError(error) && error.code === 'network_error') {
        // Network is unavailable — show offline UI or queue the request
        throw new Error('Network unavailable. Please check your connection.')
      }
      throw error
    }
  }

  return { fetchWithAuth }
}

Important

Do not import from @clerk/expo/errors — this subpath does not exist. Import isClerkRuntimeError directly from @clerk/expo.


Handling app state transitions

Mobile apps move between foreground, background, and inactive states. Each transition affects session token refresh behavior.

React Native AppState and session tokens

React Native's AppState API reports three states:

  • active: The app is in the foreground and processing events
  • background: The app is in the background (JS execution is paused)
  • inactive (iOS only): Transitional state during app switching or notification center

When the app is backgrounded, JavaScript execution pauses and Clerk's automatic token refresh stops. The session token will expire after 60 seconds in the background, but the session itself remains valid as long as it has not exceeded its maximum lifetime or inactivity timeout.

Note

Known issue: Android 14 may delay the background event (React Native issue #50415). This can cause the AppState listener to fire late on foreground return.

Refreshing sessions on foreground return

When the app returns to the foreground, force a fresh token to ensure you have a valid session:

import { useAuth } from '@clerk/expo'
import { useEffect, useRef } from 'react'
import { AppState, type AppStateStatus } from 'react-native'

export function useForegroundRefresh() {
  const { getToken, isSignedIn } = useAuth()
  const appState = useRef(AppState.currentState)

  useEffect(() => {
    if (!isSignedIn) return

    const handleAppStateChange = async (nextState: AppStateStatus) => {
      if (appState.current.match(/background|inactive/) && nextState === 'active') {
        try {
          const token = await getToken({ skipCache: true })
          if (!token) {
            // Session has expired while backgrounded
            // Navigation will be handled by the auth state change
          }
        } catch (error) {
          // Handle offline scenario — see Error Handling section
        }
      }

      appState.current = nextState
    }

    const subscription = AppState.addEventListener('change', handleAppStateChange)
    return () => subscription.remove()
  }, [isSignedIn, getToken])
}

Tip

The touchSession prop on ClerkProvider is designed around browser page-focus events and may not trigger reliably in Expo. In practice, the AppState listener above is a more reliable mobile keep-alive pattern.

Handling extended background periods

When a user returns after hours or days, the session itself may have expired (exceeded maximum lifetime) or been abandoned (inactivity timeout). In this case:

  1. getToken({ skipCache: true }) attempts to fetch a fresh token from Clerk's API
  2. The API rejects the request because the session is no longer valid
  3. In practice, the SDK's internal state management detects the invalid session and updates isSignedIn to false
  4. Your auth state listener or route guard redirects to sign-in

Important

Unlike the web SDK, the Expo SDK does not continuously poll for session validity in the background. In practice, session state updates depend on the next interaction with Clerk's API — typically triggered by getToken() or another SDK call. The useForegroundRefresh hook above ensures this check happens promptly when the app returns to the foreground.

Use isSignedIn from useAuth() as the authoritative signal for authentication status. The getToken() call and the isSignedIn transition are correlated outcomes of the same underlying event (expired session) but operate through independent code paths — do not rely on one to cause the other. Let your existing navigation guards handle the redirect when isSignedIn transitions to false.

Session persistence with SecureStore

The tokenCache from @clerk/expo/token-cache persists tokens using expo-secure-store, which provides platform-specific secure storage:

  • iOS: Keychain Services. Data persists across app uninstall if reinstalled with the same bundle ID.
  • Android: Encrypted SharedPreferences via Android Keystore. Data is cleared on uninstall.

Clerk's implementation uses a dual-slot chunked storage strategy to handle SecureStore's historical ~2,048-byte iOS limit. Without tokenCache, tokens exist in memory only and are lost when the app restarts — requiring the user to sign in again every time they close the app.


Building a session expiry UX flow

This section covers practical patterns for handling session expiry in the user interface.

Designing the redirect flow

Use isSignedIn from useAuth() as the route guard — not !!session. A truthy session object does not guarantee the user should access protected content because the session may be in a pending state with incomplete tasks. The isSignedIn boolean already incorporates treatPendingAsSignedOut logic.

In an Expo Router layout:

import { useAuth } from '@clerk/expo'
import { Redirect, Stack } from 'expo-router'
import { ActivityIndicator, View } from 'react-native'

export default function AuthenticatedLayout() {
  const { isLoaded, isSignedIn } = useAuth()

  if (!isLoaded) {
    return (
      <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
        <ActivityIndicator size="large" />
      </View>
    )
  }

  if (!isSignedIn) {
    return <Redirect href="/sign-in" />
  }

  return <Stack />
}

Routing pending sessions to task completion

When a session is pending, the user has authenticated but has incomplete tasks. Routing them to the sign-in screen is incorrect — they need to complete their task.

Clerk's ClerkProvider accepts a taskUrls prop that maps session tasks to route paths. Combined with session.currentTask (an object with a key property identifying the task), you can route pending users to the correct screen:

// In your root layout
;<ClerkProvider
  publishableKey={process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!}
  tokenCache={tokenCache}
  taskUrls={{
    'choose-organization': '/onboarding/choose-org',
    'reset-password': '/auth/reset-password',
    'setup-mfa': '/auth/setup-mfa',
  }}
>
  <Slot />
</ClerkProvider>

In your protected layout, check for pending tasks:

import { useAuth, useSession } from '@clerk/expo'
import { Redirect, Stack } from 'expo-router'
import { ActivityIndicator, View } from 'react-native'

export default function ProtectedLayout() {
  const { isLoaded, isSignedIn } = useAuth()
  const { session } = useSession()

  if (!isLoaded) {
    return (
      <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
        <ActivityIndicator size="large" />
      </View>
    )
  }

  if (!isSignedIn) return <Redirect href="/sign-in" />

  if (session?.currentTask) {
    const taskRoutes: Record<string, string> = {
      'choose-organization': '/onboarding/choose-org',
      'reset-password': '/auth/reset-password',
      'setup-mfa': '/auth/setup-mfa',
    }
    const route = taskRoutes[session.currentTask.key]
    if (route) return <Redirect href={route} />
  }

  return <Stack />
}

Note

Clerk exports <RedirectToTasks /> from @clerk/expo, but the individual Task components (<TaskChooseOrganization />, <TaskResetPassword />, <TaskSetupMFA />) are only exported from @clerk/react — not re-exported by @clerk/expo. The session.currentTask.key approach above is the most portable for Expo Router layouts.

Preserving user context with redirect URLs

When a session expires mid-use, redirect the user back to where they were after re-authentication. Configure a URL scheme in your app.json:

{
  "expo": {
    "scheme": "myapp"
  }
}

Then pass the current route when redirecting to sign-in:

import { useAuth } from '@clerk/expo'
import { usePathname, router } from 'expo-router'
import { useEffect, useRef } from 'react'

export function SessionExpiryRedirect() {
  const { isLoaded, isSignedIn } = useAuth()
  const pathname = usePathname()
  const wasSignedIn = useRef(isSignedIn)

  useEffect(() => {
    if (!isLoaded) return

    if (wasSignedIn.current && !isSignedIn) {
      router.replace({
        pathname: '/sign-in',
        params: { returnTo: pathname },
      })
    }

    wasSignedIn.current = isSignedIn
  }, [isLoaded, isSignedIn, pathname])

  return null
}

After re-authentication, navigate back:

import { useLocalSearchParams, router } from 'expo-router'
import { useEffect } from 'react'
import { useAuth } from '@clerk/expo'

export function PostSignInRedirect() {
  const { returnTo } = useLocalSearchParams<{ returnTo?: string }>()
  const { isSignedIn } = useAuth()

  useEffect(() => {
    if (!isSignedIn) return

    if (returnTo) {
      router.replace(returnTo)
    } else {
      router.replace('/(home)')
    }
  }, [isSignedIn, returnTo])

  return null
}

Silent re-authentication vs. explicit sign-in

Clerk handles two scenarios differently:

  • Silent refresh: The session token expired (60 seconds) but the client token is still valid. Clerk automatically refreshes the session token in the background. The stale-while-revalidate pattern in Core 3 means your app never blocks on this refresh. No user action is needed.
  • Explicit sign-in: The session itself expired (maximum lifetime exceeded) or was abandoned (inactivity timeout). The client token is no longer valid. The user must sign in again.

The distinction is automatic. If getToken() returns a valid token, the refresh was silent. If isSignedIn becomes false, the session is truly over.

Handling mid-transaction expiry

Protect in-progress API calls from session expiry by wrapping them with token validation and retry logic:

import { useAuth, isClerkRuntimeError } from '@clerk/expo'
import { useCallback, useRef } from 'react'

export function useProtectedApi() {
  const { getToken, isSignedIn } = useAuth()
  const isRefreshing = useRef(false)

  const callApi = useCallback(
    async (url: string, options?: RequestInit) => {
      const token = await getToken()

      if (!token) {
        throw new Error('Session expired. Please sign in again.')
      }

      const response = await fetch(url, {
        ...options,
        headers: {
          ...options?.headers,
          Authorization: `Bearer ${token}`,
        },
      })

      if (response.status === 401 && !isRefreshing.current) {
        isRefreshing.current = true
        try {
          const freshToken = await getToken({ skipCache: true })
          if (!freshToken) {
            throw new Error('Session expired. Please sign in again.')
          }

          // Retry with fresh token
          return fetch(url, {
            ...options,
            headers: {
              ...options?.headers,
              Authorization: `Bearer ${freshToken}`,
            },
          })
        } finally {
          isRefreshing.current = false
        }
      }

      return response
    },
    [getToken],
  )

  return { callApi }
}

Error handling and network resilience

Handling failures during token refresh and authentication requires distinguishing between different error types and applying the right recovery strategy.

Catching offline errors in Core 3

When the network is unavailable, Clerk throws a network_error after internal retries. Use the isClerkRuntimeError function to detect network errors:

import { isClerkRuntimeError } from '@clerk/expo'
import { Alert } from 'react-native'

async function handleOfflineError(error: unknown) {
  if (isClerkRuntimeError(error) && error.code === 'network_error') {
    Alert.alert(
      'No connection',
      'You are offline. Some features may be unavailable until your connection is restored.',
      [{ text: 'OK' }],
    )
    return true
  }
  return false
}

Retry strategies for token refresh

For network-related failures, use exponential backoff with a network state listener. Install @react-native-community/netinfo first:

npx expo install @react-native-community/netinfo
import NetInfo from '@react-native-community/netinfo'

async function retryWithBackoff<T>(fn: () => Promise<T>, maxRetries: number = 3): Promise<T> {
  let lastError: unknown

  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await fn()
    } catch (error) {
      lastError = error

      // Do not retry client errors (except 429)
      if (
        error instanceof Response &&
        error.status >= 400 &&
        error.status < 500 &&
        error.status !== 429
      ) {
        throw error
      }

      const delay = Math.min(1000 * Math.pow(2, attempt) + Math.random() * 1000, 10000)
      await new Promise((resolve) => setTimeout(resolve, delay))
    }
  }

  throw lastError
}

function onNetworkRestore(callback: () => void) {
  const unsubscribe = NetInfo.addEventListener((state) => {
    if (state.isConnected) {
      callback()
      unsubscribe()
    }
  })

  return unsubscribe
}

Handling API request failures from expired tokens

For apps using Axios, set up a response interceptor to handle 401 errors with automatic token refresh. The getClerkInstance() function provides access to the Clerk object outside React components, which is necessary for interceptors that run outside the component tree:

import axios from 'axios'
import { getClerkInstance } from '@clerk/expo'

// Replace with your API's base URL
const api = axios.create({ baseURL: 'https://api.yourapp.com' })

let isRefreshing = false
let failedQueue: Array<{
  resolve: (token: string) => void
  reject: (error: unknown) => void
}> = []

function processQueue(error: unknown, token: string | null) {
  failedQueue.forEach(({ resolve, reject }) => {
    if (error) {
      reject(error)
    } else if (token) {
      resolve(token)
    }
  })
  failedQueue = []
}

api.interceptors.request.use(async (config) => {
  const clerk = getClerkInstance()
  const token = await clerk.session?.getToken()
  if (token) {
    config.headers.Authorization = `Bearer ${token}`
  }
  return config
})

api.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config

    if (error.response?.status === 401 && !originalRequest._retry) {
      if (isRefreshing) {
        return new Promise((resolve, reject) => {
          failedQueue.push({
            resolve: (token) => {
              originalRequest.headers.Authorization = `Bearer ${token}`
              resolve(api(originalRequest))
            },
            reject,
          })
        })
      }

      originalRequest._retry = true
      isRefreshing = true

      try {
        const clerk = getClerkInstance()
        const token = await clerk.session?.getToken({ skipCache: true })

        if (!token) {
          processQueue(new Error('Session expired'), null)
          return Promise.reject(error)
        }

        processQueue(null, token)
        originalRequest.headers.Authorization = `Bearer ${token}`
        return api(originalRequest)
      } catch (refreshError) {
        processQueue(refreshError, null)
        return Promise.reject(refreshError)
      } finally {
        isRefreshing = false
      }
    }

    return Promise.reject(error)
  },
)

export { api }

Distinguishing between error types

Different errors require different responses. Use this reference to determine the correct action:

Error typeDetectionRetryableAction
Network offlineisClerkRuntimeError(err) && err.code === 'network_error'Yes (on reconnect)Show offline UI, queue requests
Session expiredsession.status === 'expired', getToken() returns nullNoRedirect to sign-in
Session abandonedsession.status === 'abandoned'NoRedirect to sign-in
Token refresh failureRefresh endpoint errorDependsRetry with backoff, then sign out
Server error (5xx)HTTP 500-599YesExponential backoff
Rate limited (429)HTTP 429YesRespect Retry-After header

Using native OAuth to reduce session friction

Native OAuth in Core 3 eliminates common session-related pain points by using platform APIs instead of browser redirects. This section is optional — native OAuth improves session establishment reliability but does not change how session expiry works once a session exists.

Note

All native OAuth examples require a development build. They will not work in Expo Go.

Browser OAuth vs. native OAuth

Browser-based OAuth (via useSSO()) opens a web browser for authentication. On Android, this approach has a documented reliability problem: one team reported that approximately 30% of their Android sign-in attempts returned a DISMISS result due to an expo-auth-session race condition (Expo issue #23781). The issue affects various phone models and Android OS versions.

Native OAuth uses platform APIs instead of browser redirects:

  • Android: Credential Manager API — fully native with no additional configuration
  • iOS: Defaults to a system browser sheet (ASWebAuthenticationSession). To enable the fully native ASAuthorization credential picker, set EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME and enable the @clerk/expo config plugin. See the Google sign-in guide for full iOS setup.

Note

useOAuth() is deprecated in Core 3. Use useSSO() for browser-based OAuth flows.

import { useSignInWithGoogle } from '@clerk/expo/google'
import { useRouter } from 'expo-router'
import { Pressable, Text, Alert } from 'react-native'

export function GoogleSignIn() {
  const { startGoogleAuthenticationFlow } = useSignInWithGoogle()
  const router = useRouter()

  const handleGoogleSignIn = async () => {
    try {
      const { createdSessionId, setActive } = await startGoogleAuthenticationFlow()

      if (createdSessionId && setActive) {
        await setActive({ session: createdSessionId })
        router.replace('/')
      }
    } catch (error: any) {
      if (error.code === 'SIGN_IN_CANCELLED' || error.message?.includes('-5')) {
        // User cancelled — no action needed
        return
      }
      Alert.alert('Error', 'Google sign-in failed. Please try again.')
    }
  }

  return (
    <Pressable onPress={handleGoogleSignIn}>
      <Text>Sign in with Google</Text>
    </Pressable>
  )
}

Important

Native Google sign-in requires: expo-crypto, development build, Google Cloud OAuth client IDs configured in Clerk Dashboard and app.json. See /docs/expo/guides/configure/auth-strategies/sign-in-with-google for full setup.

import { useSignInWithApple } from '@clerk/expo/apple'
import { useRouter } from 'expo-router'
import { Platform, Pressable, Text, Alert } from 'react-native'

export function AppleSignIn() {
  const { startAppleAuthenticationFlow } = useSignInWithApple()
  const router = useRouter()

  if (Platform.OS !== 'ios') return null

  const handleAppleSignIn = async () => {
    try {
      const { createdSessionId, setActive } = await startAppleAuthenticationFlow()

      if (createdSessionId && setActive) {
        await setActive({ session: createdSessionId })
        router.replace('/')
      }
    } catch (error: any) {
      if (error.code === 'ERR_REQUEST_CANCELED') {
        // User cancelled — no action needed
        return
      }
      Alert.alert('Error', 'Apple sign-in failed. Please try again.')
    }
  }

  return (
    <Pressable onPress={handleAppleSignIn}>
      <Text>Sign in with Apple</Text>
    </Pressable>
  )
}

Important

Native Apple sign-in requires: expo-apple-authentication, expo-crypto, development build. iOS only. See /docs/expo/guides/configure/auth-strategies/sign-in-with-apple for full setup.

How native auth improves session reliability

Native authentication creates sessions without redirect chains, removing the primary failure point in mobile OAuth. Once a session is established through native auth, it behaves identically to browser-established sessions for refresh and expiry purposes. The same token refresh, getToken(), and session monitoring patterns apply regardless of how the session was created.


Testing session expiry scenarios

Testing session management requires simulating conditions that are difficult to reproduce naturally.

Simulating session expiry in development

The Clerk Dashboard allows you to set short session lifetimes for development instances at no cost:

  1. Set Maximum session lifetime to 5 minutes
  2. Enable Inactivity timeout and set it to 2 minutes
  3. Test your expiry flows quickly without waiting for the 7-day default

You can also programmatically end a session to test the expiry flow immediately:

import { useSession } from '@clerk/expo'
import { Pressable, Text } from 'react-native'

export function ForceExpireButton() {
  const { session } = useSession()

  const handleForceExpire = async () => {
    if (session) {
      await session.end()
      // Session is now ended — your auth state listener should redirect
    }
  }

  return (
    <Pressable onPress={handleForceExpire}>
      <Text>Force session end (dev only)</Text>
    </Pressable>
  )
}

Testing background/foreground transitions

Simulate app state changes on each platform:

  • iOS Simulator: Press Cmd + Shift + H to background the app
  • Android Emulator: Press the Home button, or use adb shell am set-inactive <package-name> true
  • Test scenarios: Background for 1 minute (token expires), 5+ minutes (session may expire if configured), several hours (session definitely expires with short lifetime)

Testing offline scenarios

Simulate network failures to verify your offline error handling:

  • iOS: Use the Network Link Conditioner (download "Additional Tools for Xcode"). Presets include "100% Loss" and "Edge"
  • Android Emulator: Enable airplane mode, or use adb shell svc wifi disable && adb shell svc data disable
  • Verify: isClerkRuntimeError catches network errors, cached tokens are returned when resourceCache is enabled

Debugging session issues

Use the session object to inspect session health during development:

import { useAuth, useSession } from '@clerk/expo'
import { Text, View, ScrollView } from 'react-native'

export function SessionDebugPanel() {
  const { isLoaded, isSignedIn, sessionId } = useAuth()
  const { session } = useSession()

  if (!isLoaded) return <Text>Loading...</Text>

  return (
    <ScrollView style={{ padding: 16 }}>
      <Text style={{ fontWeight: 'bold' }}>Auth State</Text>
      <Text>isLoaded: {String(isLoaded)}</Text>
      <Text>isSignedIn: {String(isSignedIn)}</Text>
      <Text>sessionId: {sessionId ?? 'none'}</Text>

      {session && (
        <>
          <Text style={{ fontWeight: 'bold', marginTop: 16 }}>Session Details</Text>
          <Text>Status: {session.status}</Text>
          <Text>Expires: {session.expireAt?.toISOString()}</Text>
          <Text>Abandon at: {session.abandonAt?.toISOString() ?? 'No timeout'}</Text>
          <Text>Last active: {session.lastActiveAt?.toISOString()}</Text>
        </>
      )}
    </ScrollView>
  )
}

Comparison: Session management approaches in React Native

The search intent for this article is implementation-focused, so this section is kept brief. The value of this article is in its Clerk-specific operational depth, not vendor comparison.

Manual vs. managed session handling

Manual JWT management gives you full control over token refresh, storage, rotation, and race condition handling. It also requires significant implementation effort. Mobile-specific concerns — backgrounding, offline scenarios, SecureStore size limits, platform differences between iOS Keychain and Android Keystore — make manual implementation particularly error-prone. Common pitfalls include storing tokens in AsyncStorage (unencrypted), failing to rotate refresh tokens, and creating race conditions during concurrent refresh attempts.

Managed auth services like Firebase Auth, Auth0, and AWS Amplify/Cognito each provide automatic token refresh and some level of persistence. They differ in session granularity, inactivity controls, and native OAuth support. Consult each provider's current documentation for implementation details — competitive feature sets change frequently.

Clerk's approach combines automatic token refresh with stale-while-revalidate, eight granular session statuses, configurable inactivity timeout, built-in expo-secure-store integration via tokenCache, native OAuth hooks for Google and Apple, and experimental offline caching. The patterns demonstrated throughout this article require minimal custom code because Clerk handles most session lifecycle management internally.


Best practices for session management in Expo apps

  1. Always enable token caching — Never rely on in-memory-only token storage in production. Use tokenCache from @clerk/expo/token-cache so sessions survive app restarts.

  2. Handle offline states explicitly — Wrap getToken() calls in try/catch and use isClerkRuntimeError from @clerk/expo to detect network errors. Consider enabling experimental resourceCache for enhanced offline support.

  3. Use native OAuth in production — Prefer useSignInWithGoogle and useSignInWithApple over browser-based useSSO() for reliability. On Android, useSignInWithGoogle uses Credential Manager natively. On iOS, additional configuration is required for a fully native Google experience (see the Google sign-in guide). Requires a development build.

  4. Configure appropriate session lifetimes — Match session lifetime and inactivity timeout to your app's security requirements and compliance standards. Test with shorter values in development instances.

  5. Monitor app state transitions — Listen for AppState changes and proactively check session health when the app returns to the foreground. Do not rely on touchSession for mobile keep-alive.


FAQ