
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 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
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,tokenCachewithexpo-secure-storeprovides 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), andfva(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:
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-storeIf you plan to use browser-based OAuth via useSSO(), also install:
npx expo install expo-web-browser expo-auth-sessionThese 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 insidenode_modulesare not inlined in production builds.tokenCache: Persists tokens toexpo-secure-store. Always enable this in production.touchSession(defaulttrue): Clerk documents this prop as calling the Frontend APItouchendpoint during "page focus." Because page focus is a browser concept relying onwindow.focusanddocument.visibilityState,touchSessionmay not behave as expected in Expo apps. In practice, anAppState-based pattern is more reliable for mobile keep-alive (see Handling app state transitions).
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
isLoadedresolution — the SDK can initialize from cached data - Cached token return when offline
- SDK bootstraps without a network connection
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:
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-inUsing useSession() for detailed session information
The useSession() hook exposes the full session object with timing and status details:
import { useSession } from '@clerk/expo'
import { Text, View } from 'react-native'
export function SessionHealthDisplay() {
const { isLoaded, session } = useSession()
if (!isLoaded || !session) {
return null
}
return (
<View>
<Text>Status: {session.status}</Text>
<Text>Expires: {session.expireAt?.toISOString()}</Text>
<Text>Abandon at: {session.abandonAt?.toISOString() ?? 'No timeout'}</Text>
<Text>Last active: {session.lastActiveAt?.toISOString()}</Text>
</View>
)
}Monitoring session status changes
Use a useEffect to watch for session status transitions and trigger navigation:
import { useSession } from '@clerk/expo'
import { useEffect } from 'react'
import { router } from 'expo-router'
import { Alert } from 'react-native'
export function SessionStatusWatcher() {
const { session } = useSession()
useEffect(() => {
if (!session) return
const terminalStatuses = ['expired', 'ended', 'abandoned', 'removed', 'revoked']
if (terminalStatuses.includes(session.status)) {
Alert.alert('Session ended', 'Your session has expired. Please sign in again.', [
{ text: 'OK', onPress: () => router.replace('/sign-in') },
])
}
}, [session?.status])
return null
}Handling getToken() in Core 3
In Core 3, getToken() behavior depends on network availability and your configuration:
- Network available, authenticated: Returns a valid session token. Uses stale-while-revalidate to refresh proactively.
- Network unavailable, no
resourceCache: Throws a runtime error withcode: 'network_error'. This is the Core 3 breaking change — previously it returnednull. - Network unavailable,
resourceCacheenabled: Returns a cached token instead of throwing, enabling offline-capable apps. - Unauthenticated: Returns
nullregardless of network state.
The documented pattern for Expo uses isClerkRuntimeError from @clerk/expo:
import { useAuth, isClerkRuntimeError } from '@clerk/expo'
export function useAuthenticatedFetch() {
const { getToken } = useAuth()
async function fetchWithAuth(url: string, options?: RequestInit) {
try {
const token = await getToken()
if (!token) {
// User is not authenticated — redirect to sign-in
throw new Error('Not authenticated')
}
return fetch(url, {
...options,
headers: {
...options?.headers,
Authorization: `Bearer ${token}`,
},
})
} catch (error) {
if (isClerkRuntimeError(error) && error.code === 'network_error') {
// Network is unavailable — show offline UI or queue the request
throw new Error('Network unavailable. Please check your connection.')
}
throw error
}
}
return { fetchWithAuth }
}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 eventsbackground: 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.
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])
}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:
getToken({ skipCache: true })attempts to fetch a fresh token from Clerk's API- The API rejects the request because the session is no longer valid
- In practice, the SDK's internal state management detects the invalid session and updates
isSignedIntofalse - Your auth state listener or route guard redirects to sign-in
Use isSignedIn from useAuth() as the authoritative signal for authentication status. The getToken() call and the isSignedIn transition are correlated outcomes of the same underlying event (expired session) but operate through independent code paths — do not rely on one to cause the other. Let your existing navigation guards handle the redirect when isSignedIn transitions to false.
Session persistence with SecureStore
The tokenCache from @clerk/expo/token-cache persists tokens using expo-secure-store, which provides platform-specific secure storage:
- iOS: Keychain Services. Data persists across app uninstall if reinstalled with the same bundle ID.
- Android: Encrypted SharedPreferences via Android Keystore. Data is cleared on uninstall.
Clerk's implementation uses a dual-slot chunked storage strategy to handle SecureStore's historical ~2,048-byte iOS limit. Without tokenCache, tokens exist in memory only and are lost when the app restarts — requiring the user to sign in again every time they close the app.
Building a session expiry UX flow
This section covers practical patterns for handling session expiry in the user interface.
Designing the redirect flow
Use isSignedIn from useAuth() as the route guard — not !!session. A truthy session object does not guarantee the user should access protected content because the session may be in a pending state with incomplete tasks. The isSignedIn boolean already incorporates treatPendingAsSignedOut logic.
In an Expo Router layout:
import { useAuth } from '@clerk/expo'
import { Redirect, Stack } from 'expo-router'
import { ActivityIndicator, View } from 'react-native'
export default function AuthenticatedLayout() {
const { isLoaded, isSignedIn } = useAuth()
if (!isLoaded) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" />
</View>
)
}
if (!isSignedIn) {
return <Redirect href="/sign-in" />
}
return <Stack />
}Routing pending sessions to task completion
When a session is pending, the user has authenticated but has incomplete tasks. Routing them to the sign-in screen is incorrect — they need to complete their task.
Clerk's ClerkProvider accepts a taskUrls prop that maps session tasks to route paths. Combined with session.currentTask (an object with a key property identifying the task), you can route pending users to the correct screen:
// In your root layout
;<ClerkProvider
publishableKey={process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!}
tokenCache={tokenCache}
taskUrls={{
'choose-organization': '/onboarding/choose-org',
'reset-password': '/auth/reset-password',
'setup-mfa': '/auth/setup-mfa',
}}
>
<Slot />
</ClerkProvider>In your protected layout, check for pending tasks:
import { useAuth, useSession } from '@clerk/expo'
import { Redirect, Stack } from 'expo-router'
import { ActivityIndicator, View } from 'react-native'
export default function ProtectedLayout() {
const { isLoaded, isSignedIn } = useAuth()
const { session } = useSession()
if (!isLoaded) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" />
</View>
)
}
if (!isSignedIn) return <Redirect href="/sign-in" />
if (session?.currentTask) {
const taskRoutes: Record<string, string> = {
'choose-organization': '/onboarding/choose-org',
'reset-password': '/auth/reset-password',
'setup-mfa': '/auth/setup-mfa',
}
const route = taskRoutes[session.currentTask.key]
if (route) return <Redirect href={route} />
}
return <Stack />
}Preserving user context with redirect URLs
When a session expires mid-use, redirect the user back to where they were after re-authentication. Configure a URL scheme in your app.json:
{
"expo": {
"scheme": "myapp"
}
}Then pass the current route when redirecting to sign-in:
import { useAuth } from '@clerk/expo'
import { usePathname, router } from 'expo-router'
import { useEffect, useRef } from 'react'
export function SessionExpiryRedirect() {
const { isLoaded, isSignedIn } = useAuth()
const pathname = usePathname()
const wasSignedIn = useRef(isSignedIn)
useEffect(() => {
if (!isLoaded) return
if (wasSignedIn.current && !isSignedIn) {
router.replace({
pathname: '/sign-in',
params: { returnTo: pathname },
})
}
wasSignedIn.current = isSignedIn
}, [isLoaded, isSignedIn, pathname])
return null
}After re-authentication, navigate back:
import { useLocalSearchParams, router } from 'expo-router'
import { useEffect } from 'react'
import { useAuth } from '@clerk/expo'
export function PostSignInRedirect() {
const { returnTo } = useLocalSearchParams<{ returnTo?: string }>()
const { isSignedIn } = useAuth()
useEffect(() => {
if (!isSignedIn) return
if (returnTo) {
router.replace(returnTo)
} else {
router.replace('/(home)')
}
}, [isSignedIn, returnTo])
return null
}Silent re-authentication vs. explicit sign-in
Clerk handles two scenarios differently:
- Silent refresh: The session token expired (60 seconds) but the client token is still valid. Clerk automatically refreshes the session token in the background. The stale-while-revalidate pattern in Core 3 means your app never blocks on this refresh. No user action is needed.
- Explicit sign-in: The session itself expired (maximum lifetime exceeded) or was abandoned (inactivity timeout). The client token is no longer valid. The user must sign in again.
The distinction is automatic. If getToken() returns a valid token, the refresh was silent. If isSignedIn becomes false, the session is truly over.
Handling mid-transaction expiry
Protect in-progress API calls from session expiry by wrapping them with token validation and retry logic:
import { useAuth, isClerkRuntimeError } from '@clerk/expo'
import { useCallback, useRef } from 'react'
export function useProtectedApi() {
const { getToken, isSignedIn } = useAuth()
const isRefreshing = useRef(false)
const callApi = useCallback(
async (url: string, options?: RequestInit) => {
const token = await getToken()
if (!token) {
throw new Error('Session expired. Please sign in again.')
}
const response = await fetch(url, {
...options,
headers: {
...options?.headers,
Authorization: `Bearer ${token}`,
},
})
if (response.status === 401 && !isRefreshing.current) {
isRefreshing.current = true
try {
const freshToken = await getToken({ skipCache: true })
if (!freshToken) {
throw new Error('Session expired. Please sign in again.')
}
// Retry with fresh token
return fetch(url, {
...options,
headers: {
...options?.headers,
Authorization: `Bearer ${freshToken}`,
},
})
} finally {
isRefreshing.current = false
}
}
return response
},
[getToken],
)
return { callApi }
}Error handling and network resilience
Handling failures during token refresh and authentication requires distinguishing between different error types and applying the right recovery strategy.
Catching offline errors in Core 3
When the network is unavailable, Clerk throws a network_error after internal retries. Use the isClerkRuntimeError function to detect network errors:
import { isClerkRuntimeError } from '@clerk/expo'
import { Alert } from 'react-native'
async function handleOfflineError(error: unknown) {
if (isClerkRuntimeError(error) && error.code === 'network_error') {
Alert.alert(
'No connection',
'You are offline. Some features may be unavailable until your connection is restored.',
[{ text: 'OK' }],
)
return true
}
return false
}Retry strategies for token refresh
For network-related failures, use exponential backoff with a network state listener. Install @react-native-community/netinfo first:
npx expo install @react-native-community/netinfoimport NetInfo from '@react-native-community/netinfo'
async function retryWithBackoff<T>(fn: () => Promise<T>, maxRetries: number = 3): Promise<T> {
let lastError: unknown
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fn()
} catch (error) {
lastError = error
// Do not retry client errors (except 429)
if (
error instanceof Response &&
error.status >= 400 &&
error.status < 500 &&
error.status !== 429
) {
throw error
}
const delay = Math.min(1000 * Math.pow(2, attempt) + Math.random() * 1000, 10000)
await new Promise((resolve) => setTimeout(resolve, delay))
}
}
throw lastError
}
function onNetworkRestore(callback: () => void) {
const unsubscribe = NetInfo.addEventListener((state) => {
if (state.isConnected) {
callback()
unsubscribe()
}
})
return unsubscribe
}Handling API request failures from expired tokens
For apps using Axios, set up a response interceptor to handle 401 errors with automatic token refresh. The getClerkInstance() function provides access to the Clerk object outside React components, which is necessary for interceptors that run outside the component tree:
import axios from 'axios'
import { getClerkInstance } from '@clerk/expo'
// Replace with your API's base URL
const api = axios.create({ baseURL: 'https://api.yourapp.com' })
let isRefreshing = false
let failedQueue: Array<{
resolve: (token: string) => void
reject: (error: unknown) => void
}> = []
function processQueue(error: unknown, token: string | null) {
failedQueue.forEach(({ resolve, reject }) => {
if (error) {
reject(error)
} else if (token) {
resolve(token)
}
})
failedQueue = []
}
api.interceptors.request.use(async (config) => {
const clerk = getClerkInstance()
const token = await clerk.session?.getToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({
resolve: (token) => {
originalRequest.headers.Authorization = `Bearer ${token}`
resolve(api(originalRequest))
},
reject,
})
})
}
originalRequest._retry = true
isRefreshing = true
try {
const clerk = getClerkInstance()
const token = await clerk.session?.getToken({ skipCache: true })
if (!token) {
processQueue(new Error('Session expired'), null)
return Promise.reject(error)
}
processQueue(null, token)
originalRequest.headers.Authorization = `Bearer ${token}`
return api(originalRequest)
} catch (refreshError) {
processQueue(refreshError, null)
return Promise.reject(refreshError)
} finally {
isRefreshing = false
}
}
return Promise.reject(error)
},
)
export { api }Distinguishing between error types
Different errors require different responses. Use this reference to determine the correct action:
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.
Browser OAuth vs. native OAuth
Browser-based OAuth (via useSSO()) opens a web browser for authentication. On Android, this approach has a documented reliability problem: one team reported that approximately 30% of their Android sign-in attempts returned a DISMISS result due to an expo-auth-session race condition (Expo issue #23781). The issue affects various phone models and Android OS versions.
Native OAuth uses platform APIs instead of browser redirects:
- Android: Credential Manager API — fully native with no additional configuration
- iOS: Defaults to a system browser sheet (
ASWebAuthenticationSession). To enable the fully native ASAuthorization credential picker, setEXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEMEand enable the@clerk/expoconfig plugin. See the Google sign-in guide for full iOS setup.
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>
)
}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>
)
}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:
- Set Maximum session lifetime to 5 minutes
- Enable Inactivity timeout and set it to 2 minutes
- Test your expiry flows quickly without waiting for the 7-day default
You can also programmatically end a session to test the expiry flow immediately:
import { useSession } from '@clerk/expo'
import { Pressable, Text } from 'react-native'
export function ForceExpireButton() {
const { session } = useSession()
const handleForceExpire = async () => {
if (session) {
await session.end()
// Session is now ended — your auth state listener should redirect
}
}
return (
<Pressable onPress={handleForceExpire}>
<Text>Force session end (dev only)</Text>
</Pressable>
)
}Testing background/foreground transitions
Simulate app state changes on each platform:
- iOS Simulator: Press
Cmd + Shift + Hto 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:
isClerkRuntimeErrorcatches network errors, cached tokens are returned whenresourceCacheis enabled
Debugging session issues
Use the session object to inspect session health during development:
import { useAuth, useSession } from '@clerk/expo'
import { Text, View, ScrollView } from 'react-native'
export function SessionDebugPanel() {
const { isLoaded, isSignedIn, sessionId } = useAuth()
const { session } = useSession()
if (!isLoaded) return <Text>Loading...</Text>
return (
<ScrollView style={{ padding: 16 }}>
<Text style={{ fontWeight: 'bold' }}>Auth State</Text>
<Text>isLoaded: {String(isLoaded)}</Text>
<Text>isSignedIn: {String(isSignedIn)}</Text>
<Text>sessionId: {sessionId ?? 'none'}</Text>
{session && (
<>
<Text style={{ fontWeight: 'bold', marginTop: 16 }}>Session Details</Text>
<Text>Status: {session.status}</Text>
<Text>Expires: {session.expireAt?.toISOString()}</Text>
<Text>Abandon at: {session.abandonAt?.toISOString() ?? 'No timeout'}</Text>
<Text>Last active: {session.lastActiveAt?.toISOString()}</Text>
</>
)}
</ScrollView>
)
}Comparison: Session management approaches in React Native
The search intent for this article is implementation-focused, so this section is kept brief. The value of this article is in its Clerk-specific operational depth, not vendor comparison.
Manual vs. managed session handling
Manual JWT management gives you full control over token refresh, storage, rotation, and race condition handling. It also requires significant implementation effort. Mobile-specific concerns — backgrounding, offline scenarios, SecureStore size limits, platform differences between iOS Keychain and Android Keystore — make manual implementation particularly error-prone. Common pitfalls include storing tokens in AsyncStorage (unencrypted), failing to rotate refresh tokens, and creating race conditions during concurrent refresh attempts.
Managed auth services like Firebase Auth, Auth0, and AWS Amplify/Cognito each provide automatic token refresh and some level of persistence. They differ in session granularity, inactivity controls, and native OAuth support. Consult each provider's current documentation for implementation details — competitive feature sets change frequently.
Clerk's approach combines automatic token refresh with stale-while-revalidate, eight granular session statuses, configurable inactivity timeout, built-in expo-secure-store integration via tokenCache, native OAuth hooks for Google and Apple, and experimental offline caching. The patterns demonstrated throughout this article require minimal custom code because Clerk handles most session lifecycle management internally.
Best practices for session management in Expo apps
-
Always enable token caching — Never rely on in-memory-only token storage in production. Use
tokenCachefrom@clerk/expo/token-cacheso sessions survive app restarts. -
Handle offline states explicitly — Wrap
getToken()calls in try/catch and useisClerkRuntimeErrorfrom@clerk/expoto detect network errors. Consider enabling experimentalresourceCachefor enhanced offline support. -
Use native OAuth in production — Prefer
useSignInWithGoogleanduseSignInWithAppleover browser-baseduseSSO()for reliability. On Android,useSignInWithGoogleuses Credential Manager natively. On iOS, additional configuration is required for a fully native Google experience (see the Google sign-in guide). Requires a development build. -
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.
-
Monitor app state transitions — Listen for
AppStatechanges and proactively check session health when the app returns to the foreground. Do not rely ontouchSessionfor mobile keep-alive.