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

> Part 2 of 2. Start with [How to Handle Session Expiry in a React Native App with Clerk](https://clerk.com/articles/how-to-handle-session-expiry-in-a-react-native-app-with-clerk.md).

# 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:

```tsx
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:

```tsx
// 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:

```tsx
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 />
}
```

> `@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`:

```json
{
  "expo": {
    "scheme": "myapp"
  }
}
```

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

```tsx
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:

```tsx
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:

```tsx
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:

```tsx
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
}
```

> 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:

```bash
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:

```tsx
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](https://clerk.com/docs/guides/development/access-clerk-outside-components.md), which is necessary for interceptors that run outside the component tree:

```tsx
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 type            | Detection                                                   | Retryable          | Action                            |
| --------------------- | ----------------------------------------------------------- | ------------------ | --------------------------------- |
| Network offline       | `ClerkOfflineError.is(err)` (thrown by `getToken()`)        | Yes (on reconnect) | Show offline UI, queue requests   |
| Session expired       | `session.status === 'expired'`, `getToken()` returns `null` | No                 | Redirect to sign-in               |
| Session abandoned     | `session.status === 'abandoned'`                            | No                 | Redirect to sign-in               |
| Token refresh failure | Refresh endpoint error                                      | Depends            | Retry with backoff, then sign out |
| Server error (5xx)    | HTTP 500-599                                                | Yes                | Exponential backoff               |
| Rate limited (429)    | HTTP 429                                                    | Yes                | Respect `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.

> 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](https://github.com/expo/expo/issues/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](https://clerk.com/docs/expo/guides/configure/auth-strategies/sign-in-with-google.md) for full iOS setup.

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

### Setting up native Google sign-in

```tsx
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>
  )
}
```

> 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.

### Setting up native Apple sign-in

```tsx
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>
  )
}
```

> 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:

```tsx
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:

```tsx
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](https://clerk.com/docs/expo/guides/configure/auth-strategies/sign-in-with-google.md)). 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](https://clerk.com/articles/how-to-handle-session-expiry-in-a-react-native-app-with-clerk.md)
2. **How to Handle Session Expiry in a React Native App with Clerk - Part 2** (you are here)
