Skip to main content
Articles

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

Author: Roy Anger
Published: (last updated )

This is Part 1 of a two-part series on handling session expiry in React Native apps with Clerk. This part covers the core mechanics: architecture, setup, configuration, detection, and app state transitions. Part 2 covers UX flows, error handling, native OAuth, and testing.

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

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

What you'll learn

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

Note

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


How Clerk sessions work in React Native

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

The two-token architecture

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

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

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

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

Automatic token refresh

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

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

Session states

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

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

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

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

Authentication states in React Native

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

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

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


Setting up Clerk in an Expo app

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

Installing dependencies

Install the core packages:

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

Important

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

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

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

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

Configuring ClerkProvider with token caching

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

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

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

Key props:

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

Note

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

Enabling offline support (experimental)

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

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

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

Benefits of resourceCache:

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

Warning

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


Configuring session lifetime and inactivity timeout

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

Maximum session lifetime

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

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

Inactivity timeout

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

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

Choosing the right configuration for mobile

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

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

Note

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

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


Detecting session expiry in your app

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

Handling the initialization window

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

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

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

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

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

  return <Stack />
}

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

Using useAuth() to monitor authentication state

The useAuth() hook provides the core authentication state:

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

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

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

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

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

  return null
}

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

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

Using useSession() for detailed session information

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

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

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

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

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

Monitoring session status changes

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

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

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

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

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

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

  return null
}

Handling getToken() in Core 3

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

  • Network available, authenticated: Returns a valid session token. Uses stale-while-revalidate to refresh proactively.
  • Network unavailable, no resourceCache: Retries briefly (about 15 seconds), then throws a ClerkOfflineError. This is the Core 3 breaking change — previously it returned null, which was indistinguishable from the signed-out case.
  • Network unavailable, resourceCache enabled: Returns a cached token instead of throwing, enabling offline-capable apps.
  • Unauthenticated: Returns null regardless of network state.

Catch the offline case with the ClerkOfflineError.is() type guard — the canonical Core 3 pattern:

import { useAuth } from '@clerk/expo'
import { ClerkOfflineError } from '@clerk/react/errors'

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

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

      if (!token) {
        // getToken() returns null when the user is signed out — redirect to sign-in
        throw new Error('Not authenticated')
      }

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

  return { fetchWithAuth }
}

Important

Import ClerkOfflineError from @clerk/react/errors. The @clerk/expo package does not re-export ClerkOfflineError from its entry point, and there is no @clerk/expo/errors subpath — but @clerk/expo depends on @clerk/react directly, so @clerk/react/errors resolves in any Expo project.


Handling app state transitions

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

React Native AppState and session tokens

React Native's AppState API reports three states:

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

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

Note

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

Refreshing sessions on foreground return

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

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

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

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

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

      appState.current = nextState
    }

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

Tip

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

Handling extended background periods

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

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

Important

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

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

Session persistence with SecureStore

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

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

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

Conclusion

In this first part, we covered the foundational mechanics of session expiry in React Native apps using Clerk. You learned how the two-token architecture works, how to configure session lifetimes, and how to detect expiry and handle app state transitions. In Part 2, we will build on these mechanics to implement resilient UX flows, handle network errors, use native OAuth, and test your session management implementation.

FAQ

What is the difference between an expired and an abandoned session? An expired session has exceeded its maximum configured lifetime. An abandoned session has exceeded its inactivity timeout without being refreshed. Both require the user to sign in again.

Why does my app redirect to sign-in immediately on load? This usually happens when you check isSignedIn before isLoaded is true. Always wait for isLoaded to be true before evaluating authentication state.

In this series

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