Skip to main content
Articles

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

Author: Roy Anger
Published: (last updated )

How to handle session expiry in a React Native app with Clerk - Part 2

This is Part 2 of a two-part series on handling session expiry in React Native apps with Clerk. Part 1 covered the core mechanics, setup, and detection of session expiry. This part focuses on building resilient UX flows, handling network failures, using native OAuth, and testing your implementation.

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 task keys to route paths. Combined with session.currentTask (a SessionTask object whose key is one of choose-organization, reset-password, or setup-mfa), 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/expo re-exports <RedirectToTasks /> (from @clerk/react), but it does not export the individual prebuilt Task components — <TaskChooseOrganization />, <TaskResetPassword />, and <TaskSetupMFA />. Those ship only in @clerk/react, and this holds on both native and Expo web: the @clerk/expo/web entry point exposes a fixed set of UI components (SignIn, SignUp, UserButton, OrganizationSwitcher, and similar) that does not include any Task component. The session.currentTask.key routing above is the most portable approach for Expo Router layouts; if you specifically need a prebuilt Task component, import it from @clerk/react.

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 } 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 device is offline, getToken() throws a ClerkOfflineError after a short internal retry period (about 15 seconds) instead of returning null. Use the ClerkOfflineError.is() type guard to detect it — the canonical Core 3 offline pattern:

import { ClerkOfflineError } from '@clerk/react/errors'
import { Alert } from 'react-native'

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

Note

Import ClerkOfflineError from @clerk/react/errors. @clerk/expo does not provide an @clerk/expo/errors subpath and does not re-export ClerkOfflineError from its entry point, but it depends on @clerk/react directly, so @clerk/react/errors resolves in any Expo project.

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

Then implement a backoff helper that retries transient failures and a listener that resumes work when connectivity returns:

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 offlineClerkOfflineError.is(err) (thrown by getToken())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: ClerkOfflineError.is() catches the offline error thrown by getToken(), 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 ClerkOfflineError.is() from @clerk/react/errors to detect the offline error. 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 — it is wired to browser page-focus events that do not exist in React Native, so it has no effect on native.

Conclusion

Handling session expiry gracefully is essential for a robust mobile user experience. By implementing proper UX flows, handling network errors, and utilizing native OAuth, you ensure that users can seamlessly recover from expired sessions or network interruptions. Combined with the foundational mechanics from Part 1, you now have a complete, production-ready approach to session management in Expo apps with Clerk.

FAQ

How do I test session expiry without waiting 7 days? You can configure a short maximum session lifetime (e.g., 5 minutes) and inactivity timeout (e.g., 2 minutes) in the Clerk Dashboard for your development instance.

Why should I use native OAuth instead of browser-based OAuth? Native OAuth uses platform APIs (like Credential Manager on Android) instead of browser redirects, which eliminates common race conditions and reliability issues associated with opening and closing external browsers on mobile devices.

In this series

  1. How to Handle Session Expiry in a React Native App with Clerk
  2. How to Handle Session Expiry in a React Native App with Clerk - Part 2 (you are here)