
How to Handle Session Expiry in a React Native App with Clerk - Part 2
Part 2 of 2. Start with How to Handle Session Expiry in a React Native App with Clerk.
How to handle session expiry in a React Native app with Clerk - Part 2
This is Part 2 of a two-part series on handling session expiry in React Native apps with Clerk. Part 1 covered the core mechanics, setup, and detection of session expiry. This part focuses on building resilient UX flows, handling network failures, using native OAuth, and testing your implementation.
Building a session expiry UX flow
This section covers practical patterns for handling session expiry in the user interface.
Designing the redirect flow
Use isSignedIn from useAuth() as the route guard — not !!session. A truthy session object does not guarantee the user should access protected content because the session may be in a pending state with incomplete tasks. The isSignedIn boolean already incorporates treatPendingAsSignedOut logic.
In an Expo Router layout:
import { useAuth } from '@clerk/expo'
import { Redirect, Stack } from 'expo-router'
import { ActivityIndicator, View } from 'react-native'
export default function AuthenticatedLayout() {
const { isLoaded, isSignedIn } = useAuth()
if (!isLoaded) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" />
</View>
)
}
if (!isSignedIn) {
return <Redirect href="/sign-in" />
}
return <Stack />
}Routing pending sessions to task completion
When a session is pending, the user has authenticated but has incomplete tasks. Routing them to the sign-in screen is incorrect — they need to complete their task.
Clerk's ClerkProvider accepts a taskUrls prop that maps session task keys to route paths. Combined with session.currentTask (a SessionTask object whose key is one of choose-organization, reset-password, or setup-mfa), you can route pending users to the correct screen:
// In your root layout
;<ClerkProvider
publishableKey={process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!}
tokenCache={tokenCache}
taskUrls={{
'choose-organization': '/onboarding/choose-org',
'reset-password': '/auth/reset-password',
'setup-mfa': '/auth/setup-mfa',
}}
>
<Slot />
</ClerkProvider>In your protected layout, check for pending tasks:
import { useAuth, useSession } from '@clerk/expo'
import { Redirect, Stack } from 'expo-router'
import { ActivityIndicator, View } from 'react-native'
export default function ProtectedLayout() {
const { isLoaded, isSignedIn } = useAuth()
const { session } = useSession()
if (!isLoaded) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" />
</View>
)
}
if (!isSignedIn) return <Redirect href="/sign-in" />
if (session?.currentTask) {
const taskRoutes: Record<string, string> = {
'choose-organization': '/onboarding/choose-org',
'reset-password': '/auth/reset-password',
'setup-mfa': '/auth/setup-mfa',
}
const route = taskRoutes[session.currentTask.key]
if (route) return <Redirect href={route} />
}
return <Stack />
}Preserving user context with redirect URLs
When a session expires mid-use, redirect the user back to where they were after re-authentication. Configure a URL scheme in your app.json:
{
"expo": {
"scheme": "myapp"
}
}Then pass the current route when redirecting to sign-in:
import { useAuth } from '@clerk/expo'
import { usePathname, router } from 'expo-router'
import { useEffect, useRef } from 'react'
export function SessionExpiryRedirect() {
const { isLoaded, isSignedIn } = useAuth()
const pathname = usePathname()
const wasSignedIn = useRef(isSignedIn)
useEffect(() => {
if (!isLoaded) return
if (wasSignedIn.current && !isSignedIn) {
router.replace({
pathname: '/sign-in',
params: { returnTo: pathname },
})
}
wasSignedIn.current = isSignedIn
}, [isLoaded, isSignedIn, pathname])
return null
}After re-authentication, navigate back:
import { useLocalSearchParams, router } from 'expo-router'
import { useEffect } from 'react'
import { useAuth } from '@clerk/expo'
export function PostSignInRedirect() {
const { returnTo } = useLocalSearchParams<{ returnTo?: string }>()
const { isSignedIn } = useAuth()
useEffect(() => {
if (!isSignedIn) return
if (returnTo) {
router.replace(returnTo)
} else {
router.replace('/(home)')
}
}, [isSignedIn, returnTo])
return null
}Silent re-authentication vs. explicit sign-in
Clerk handles two scenarios differently:
- Silent refresh: The session token expired (60 seconds) but the client token is still valid. Clerk automatically refreshes the session token in the background. The stale-while-revalidate pattern in Core 3 means your app never blocks on this refresh. No user action is needed.
- Explicit sign-in: The session itself expired (maximum lifetime exceeded) or was abandoned (inactivity timeout). The client token is no longer valid. The user must sign in again.
The distinction is automatic. If getToken() returns a valid token, the refresh was silent. If isSignedIn becomes false, the session is truly over.
Handling mid-transaction expiry
Protect in-progress API calls from session expiry by wrapping them with token validation and retry logic:
import { useAuth } from '@clerk/expo'
import { useCallback, useRef } from 'react'
export function useProtectedApi() {
const { getToken, isSignedIn } = useAuth()
const isRefreshing = useRef(false)
const callApi = useCallback(
async (url: string, options?: RequestInit) => {
const token = await getToken()
if (!token) {
throw new Error('Session expired. Please sign in again.')
}
const response = await fetch(url, {
...options,
headers: {
...options?.headers,
Authorization: `Bearer ${token}`,
},
})
if (response.status === 401 && !isRefreshing.current) {
isRefreshing.current = true
try {
const freshToken = await getToken({ skipCache: true })
if (!freshToken) {
throw new Error('Session expired. Please sign in again.')
}
// Retry with fresh token
return fetch(url, {
...options,
headers: {
...options?.headers,
Authorization: `Bearer ${freshToken}`,
},
})
} finally {
isRefreshing.current = false
}
}
return response
},
[getToken],
)
return { callApi }
}Error handling and network resilience
Handling failures during token refresh and authentication requires distinguishing between different error types and applying the right recovery strategy.
Catching offline errors in Core 3
When the device is offline, getToken() throws a ClerkOfflineError after a short internal retry period (about 15 seconds) instead of returning null. Use the ClerkOfflineError.is() type guard to detect it — the canonical Core 3 offline pattern:
import { ClerkOfflineError } from '@clerk/react/errors'
import { Alert } from 'react-native'
async function handleOfflineError(error: unknown) {
if (ClerkOfflineError.is(error)) {
Alert.alert(
'No connection',
'You are offline. Some features may be unavailable until your connection is restored.',
[{ text: 'OK' }],
)
return true
}
return false
}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/netinfoThen implement a backoff helper that retries transient failures and a listener that resumes work when connectivity returns:
import NetInfo from '@react-native-community/netinfo'
async function retryWithBackoff<T>(fn: () => Promise<T>, maxRetries: number = 3): Promise<T> {
let lastError: unknown
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fn()
} catch (error) {
lastError = error
// Do not retry client errors (except 429)
if (
error instanceof Response &&
error.status >= 400 &&
error.status < 500 &&
error.status !== 429
) {
throw error
}
const delay = Math.min(1000 * Math.pow(2, attempt) + Math.random() * 1000, 10000)
await new Promise((resolve) => setTimeout(resolve, delay))
}
}
throw lastError
}
function onNetworkRestore(callback: () => void) {
const unsubscribe = NetInfo.addEventListener((state) => {
if (state.isConnected) {
callback()
unsubscribe()
}
})
return unsubscribe
}Handling API request failures from expired tokens
For apps using Axios, set up a response interceptor to handle 401 errors with automatic token refresh. The getClerkInstance() function provides access to the Clerk object outside React components, which is necessary for interceptors that run outside the component tree:
import axios from 'axios'
import { getClerkInstance } from '@clerk/expo'
// Replace with your API's base URL
const api = axios.create({ baseURL: 'https://api.yourapp.com' })
let isRefreshing = false
let failedQueue: Array<{
resolve: (token: string) => void
reject: (error: unknown) => void
}> = []
function processQueue(error: unknown, token: string | null) {
failedQueue.forEach(({ resolve, reject }) => {
if (error) {
reject(error)
} else if (token) {
resolve(token)
}
})
failedQueue = []
}
api.interceptors.request.use(async (config) => {
const clerk = getClerkInstance()
const token = await clerk.session?.getToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({
resolve: (token) => {
originalRequest.headers.Authorization = `Bearer ${token}`
resolve(api(originalRequest))
},
reject,
})
})
}
originalRequest._retry = true
isRefreshing = true
try {
const clerk = getClerkInstance()
const token = await clerk.session?.getToken({ skipCache: true })
if (!token) {
processQueue(new Error('Session expired'), null)
return Promise.reject(error)
}
processQueue(null, token)
originalRequest.headers.Authorization = `Bearer ${token}`
return api(originalRequest)
} catch (refreshError) {
processQueue(refreshError, null)
return Promise.reject(refreshError)
} finally {
isRefreshing = false
}
}
return Promise.reject(error)
},
)
export { api }Distinguishing between error types
Different errors require different responses. Use this reference to determine the correct action:
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:
ClerkOfflineError.is()catches the offline error thrown bygetToken(), 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 useClerkOfflineError.is()from@clerk/react/errorsto detect the offline error. 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 — it is wired to browser page-focus events that do not exist in React Native, so it has no effect on native.
Conclusion
Handling session expiry gracefully is essential for a robust mobile user experience. By implementing proper UX flows, handling network errors, and utilizing native OAuth, you ensure that users can seamlessly recover from expired sessions or network interruptions. Combined with the foundational mechanics from Part 1, you now have a complete, production-ready approach to session management in Expo apps with Clerk.
FAQ
How do I test session expiry without waiting 7 days? You can configure a short maximum session lifetime (e.g., 5 minutes) and inactivity timeout (e.g., 2 minutes) in the Clerk Dashboard for your development instance.
Why should I use native OAuth instead of browser-based OAuth? Native OAuth uses platform APIs (like Credential Manager on Android) instead of browser redirects, which eliminates common race conditions and reliability issues associated with opening and closing external browsers on mobile devices.
In this series
- How to Handle Session Expiry in a React Native App with Clerk
- How to Handle Session Expiry in a React Native App with Clerk - Part 2 (you are here)