
How to Handle Session Expiry in a React Native App with Clerk
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
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: Retries briefly (about 15 seconds), then throws aClerkOfflineError. This is the Core 3 breaking change — previously it returnednull, which was indistinguishable from the signed-out case. - Network unavailable,
resourceCacheenabled: Returns a cached token instead of throwing, enabling offline-capable apps. - Unauthenticated: Returns
nullregardless 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 }
}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.
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
- How to Handle Session Expiry in a React Native App with Clerk (you are here)
- How to Handle Session Expiry in a React Native App with Clerk - Part 2