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

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](https://clerk.com/glossary/session.md) 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](https://clerk.com/docs/guides/how-clerk-works/overview.md) manages sessions in [Expo](https://clerk.com/glossary/expo.md) apps, from automatic [token refresh](https://clerk.com/glossary/token-refresh.md) to offline resilience, and walks you through building a production-ready [session management](https://clerk.com/glossary/session-management.md) 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

> 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](https://clerk.com/docs/guides/how-clerk-works/overview.md) 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](https://clerk.com/glossary/session-token.md) ([JWT](https://clerk.com/glossary/json-web-token.md))**: 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:

| Status      | Trigger                                                                                                                                                 | Developer action        |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- |
| `active`    | Session is current and valid                                                                                                                            | Normal operation        |
| `pending`   | User authenticated but has incomplete tasks (org selection, [MFA](https://clerk.com/glossary/multi-factor-authentication-mfa.md) setup, password reset) | Show task completion UI |
| `ended`     | `session.end()` called client-side                                                                                                                      | Redirect to sign-in     |
| `expired`   | Exceeded maximum session lifetime                                                                                                                       | Redirect to sign-in     |
| `removed`   | `session.remove()` called client-side                                                                                                                   | Redirect to sign-in     |
| `abandoned` | Inactivity timeout triggered                                                                                                                            | Redirect to sign-in     |
| `replaced`  | Another session took over (multi-session apps)                                                                                                          | Handle gracefully       |
| `revoked`   | Admin or backend revoked session via Backend API                                                                                                        | Redirect 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](https://clerk.com/glossary/authentication.md) context. Your route guards must distinguish "session pending a task" from "session expired" to show the correct UI. See the [Detecting session expiry](#detecting-session-expiry-in-your-app) 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](https://clerk.com/docs/expo/getting-started/quickstart.md).

### Installing dependencies

Install the core packages:

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

> 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](https://clerk.com/glossary/oauth.md) via `useSSO()`, also install:

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

```tsx
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](https://clerk.com/glossary/publishable-key.md). 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](#handling-app-state-transitions)).

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

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

> 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](https://clerk.com/docs/guides/secure/session-options.md) 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 category          | Idle timeout         | Session lifetime     | Reference                                                                                    |
| --------------------- | -------------------- | -------------------- | -------------------------------------------------------------------------------------------- |
| Banking / Financial   | 15 minutes           | 12 hours             | PCI DSS 8.2.8 (idle); NIST SP 800-63B AAL2 (lifetime)                                        |
| Healthcare            | Organization-defined | Organization-defined | HIPAA §164.312(a)(2)(iii) — requires automatic logoff but does not prescribe specific values |
| E-commerce            | 15-30 minutes        | 24 hours             | Industry practice, consistent with OWASP guidance                                            |
| Social / Consumer     | 30+ minutes          | 30+ days             | UX-driven, aligns with NIST SP 800-63B AAL1 (30-day reauthentication)                        |
| Internal / Enterprise | 15 minutes           | 12 hours             | NIST SP 800-63B AAL3 (idle); AAL2/AAL3 (lifetime)                                            |

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

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

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

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

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

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

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

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

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

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

> 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

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

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

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

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

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

```bash
npx expo install @react-native-community/netinfo
```

```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       | `isClerkRuntimeError(err) && err.code === 'network_error'`  | 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**: `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:

```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 `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](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.

***

## FAQ

## FAQ

### How long do Clerk session tokens last in React Native?

Clerk session tokens have a 60-second lifetime. The SDK automatically refreshes them on a recurring interval matching this lifetime. The overall session lifetime (default 7 days) is configurable in the Clerk Dashboard from 5 minutes to 10 years. Customizing session lifetime requires a paid plan in production but is free for development instances.

### What happens when my Expo app goes to the background?

When the app is backgrounded, the JavaScript runtime pauses and token refresh stops. The session token expires after 60 seconds in the background. When the app returns to the foreground, Clerk resumes refreshing. If the session token expired but the session is still valid, Clerk automatically fetches a new token. If the session itself expired (exceeded maximum lifetime) or was abandoned (inactivity timeout), the user needs to sign in again.

### How do I handle session expiry when the user is offline?

In Core 3, `getToken()` throws a runtime error with `code: 'network_error'` when the network is unavailable by default. Catch this using `isClerkRuntimeError(error)` from `@clerk/expo` and show an appropriate offline state. When the experimental `__experimental_resourceCache` is enabled, `getToken()` returns a cached token instead of throwing, enabling limited offline functionality.

### What is the difference between session expiry and inactivity timeout?

Session expiry (maximum lifetime) ends the session after a fixed duration regardless of activity — the session transitions to `expired`. Inactivity timeout ends the session after a period without token refreshes — the session transitions to `abandoned`. Both are configurable in the Clerk Dashboard. At least one must be enabled.

### How do I redirect users back to their previous screen after re-authentication?

Configure a URL scheme in your Expo `app.json` (`"scheme": "myapp"`). When the session expires, pass the current route as a parameter when redirecting to the sign-in screen using `router.replace({ pathname: '/sign-in', params: { returnTo: pathname } })`. After re-authentication, read the `returnTo` parameter and navigate back.

### Does Clerk handle token refresh automatically in React Native?

Yes. Clerk refreshes session tokens on a recurring interval automatically. In Core 3, `getToken()` uses a stale-while-revalidate strategy — it returns the cached token immediately when the token is within 15 seconds of expiry and triggers a background refresh. Your app never blocks on a token refresh during normal operation.

### How do I force a token refresh after updating user data?

Call `getToken({ skipCache: true })` to bypass the cache and fetch a fresh token with updated claims. You can also call `user.reload()` from `useUser()`, which refreshes both the user object and the session token.

### What changed with session handling in Clerk Core 3?

Key changes: `getToken()` now throws on network errors instead of returning `null` (by default — with `__experimental_resourceCache`, it returns cached tokens instead). The stale-while-revalidate pattern was added for proactive background refresh. The Expo package was renamed from `@clerk/clerk-expo` to `@clerk/expo`. `useSSO()` replaces the deprecated `useOAuth()` for browser-based OAuth.

### Can I test session expiry in development without waiting for real timeouts?

Yes. Set a short session lifetime (for example, 5 minutes) and inactivity timeout in the Clerk Dashboard for your development instance — this is free. You can also programmatically end a session using `session.end()` from `useSession()` to test the expiry flow immediately.

### Is the offline support feature stable for production use?

The `__experimental_resourceCache` for offline support is experimental — the API may change and the prefix indicates instability. The `tokenCache` for basic token persistence is stable and recommended for all production apps. The offline error handling with `isClerkRuntimeError` and the `network_error` code is stable in Core 3.

***
