
How to Protect Routes in Expo Router with Clerk - Part 2
Part 2 of 2. Start with How to Protect Routes in Expo Router with Clerk.
This is Part 2 of a two-part series on protecting routes in Expo Router with Clerk. In Part 1, we covered the core setup, route group architecture, and building essential authentication screens. In this part, we dive into advanced routing patterns, role-based access control, and handling deep links.
In the first part of this series, we established a secure foundation for an Expo Router application using Clerk. We separated our public and private routes, implemented layout guards, and built the core authentication flows. Now, we'll tackle the more complex scenarios you'll encounter in production applications. We'll implement role-based access control to restrict certain screens to administrators, secure nested tab navigators, handle deep links that require authentication, and explore how to manage token persistence for offline support.
Role-based access control (RBAC)
Setting up roles in Clerk
For apps that don't use Clerk Organizations, the recommended approach is storing roles in publicMetadata on the user object.
Clerk has three metadata types on the user object:
publicMetadata is the right choice for roles because it's readable from the frontend (so your layout guards can check it) but only writable from a backend API (so users can't escalate their own privileges).
Set a user's role using the Clerk backend SDK:
app/api/set-role+api.ts
import { clerkClient } from '@clerk/express'
export async function POST(request: Request) {
const requestState = await clerkClient.authenticateRequest(request)
const auth = requestState.toAuth()
if (!auth.userId || auth.sessionClaims?.metadata?.role !== 'admin') {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
const { userId, role } = await request.json()
await clerkClient.users.updateUserMetadata(userId, {
publicMetadata: { role },
})
return Response.json({ success: true })
}The same pattern works with any Node.js backend (Express, Hono, Fastify, etc.), not just Expo Router API routes.
Next, customize the session token to include role data. In the Clerk Dashboard, navigate to the Sessions page. Under Customize session token, in the Claims editor, add the following and select Save:
{
"metadata": "{{user.public_metadata}}"
}This makes the role available in the session token, so you can read it client-side without a separate API call. Custom claims are limited to about 1.2KB (constrained by the 4KB cookie size limit after Clerk's default claims).
TypeScript type definitions
Make the custom claims type-safe by adding a global type declaration:
types/globals.d.ts
export {}
type Roles = 'admin' | 'moderator' | 'user'
declare global {
interface CustomJwtSessionClaims {
metadata?: {
role?: Roles
}
}
}Reading roles and protecting routes by role
Read the role from sessionClaims via useAuth():
utils/roles.ts
import { useAuth } from '@clerk/expo'
export function useRole() {
const { sessionClaims } = useAuth()
return sessionClaims?.metadata?.role
}
export function useIsAdmin() {
return useRole() === 'admin'
}Protect admin routes with a layout guard:
app/(app)/admin/_layout.tsx
import { useAuth } from '@clerk/expo'
import { Redirect, Stack } from 'expo-router'
export default function AdminLayout() {
const { isLoaded, sessionClaims } = useAuth()
const role = sessionClaims?.metadata?.role
if (!isLoaded) return null
if (role !== 'admin') {
return <Redirect href="/(app)" />
}
return (
<Stack>
<Stack.Screen name="index" options={{ title: 'Admin Dashboard' }} />
</Stack>
)
}With Stack.Protected, you can set the guard at the parent layout level instead:
// In app/(app)/_layout.tsx
;<Stack.Protected guard={role === 'admin'}>
<Stack.Screen name="admin" />
</Stack.Protected>Server-side validation is essential. Client-side role checks are for UX, not security. Always verify roles on your backend before granting access to sensitive data or operations.
Feature-based access with Organizations
For B2B multi-tenant apps, Clerk Organizations provide built-in RBAC with the has() function, custom roles, and custom permissions.
import { Show } from '@clerk/expo'
import { View } from 'react-native'
export default function Dashboard() {
return (
<View>
{/* Permission-based UI */}
<Show when={{ permission: 'org:posts:edit' }}>
<EditButton />
</Show>
{/* Plan-based UI */}
<Show when={{ plan: 'premium' }}>
<PremiumFeatures />
</Show>
{/* Feature-based UI */}
<Show when={{ feature: 'premium_access' }}>
<AdvancedAnalytics />
</Show>
</View>
)
}The has() function supports 4 parameter shapes: role (org-scoped), permission (org-scoped), feature (user or org-scoped), and plan (user or org-scoped). Plans and features work at the user level too, meaning B2C apps without Organizations can use has({ plan: 'premium' }) and has({ feature: 'premium_access' }). The org:resource:action namespace convention applies only to roles and permissions, not to plans or features, which use plain strings.
Session tokens have a 60-second default lifetime and refresh automatically before expiry. Role changes propagate on the next token refresh. For immediate updates, use getToken({ skipCache: true }) to force a fresh token, or user.reload() to refresh user data.
Conditional UI based on roles
Hide navigation elements based on the user's role. For example, conditionally hide the admin tab for non-admin users (full tab layout implementation in the next section):
<Tabs.Screen
name="admin"
options={{
title: 'Admin',
href: role === 'admin' ? '/(app)/admin' : null,
}}
/>Setting href to null hides the tab from the navigation bar while keeping the route defined. Non-admin users won't see the tab, and the admin layout guard catches any direct access attempts.
Tab navigators with protected routes
Setting up protected tabs
Here's the full (app) layout with tabs and a conditional admin tab:
app/(app)/_layout.tsx
import { useAuth } from '@clerk/expo'
import { Redirect, Tabs } from 'expo-router'
import { Ionicons } from '@expo/vector-icons'
export default function AppLayout() {
const { isSignedIn, isLoaded, sessionClaims } = useAuth()
const role = sessionClaims?.metadata?.role
if (!isLoaded) return null
// Only needed if NOT using Stack.Protected in root layout
if (!isSignedIn) {
return <Redirect href="/(auth)/sign-in" />
}
return (
<Tabs screenOptions={{ headerShown: true }}>
<Tabs.Screen
name="index"
options={{
title: 'Home',
tabBarIcon: ({ color, size }) => <Ionicons name="home" color={color} size={size} />,
}}
/>
<Tabs.Screen
name="profile"
options={{
title: 'Profile',
tabBarIcon: ({ color, size }) => <Ionicons name="person" color={color} size={size} />,
}}
/>
<Tabs.Screen
name="admin"
options={{
title: 'Admin',
href: role === 'admin' ? '/(app)/admin' : null,
tabBarIcon: ({ color, size }) => <Ionicons name="shield" color={color} size={size} />,
}}
/>
</Tabs>
)
}When href is null, the tab disappears from the tab bar. When the user's role changes (for instance, they're promoted to admin), the tab appears on the next render.
Nested stack navigation within tabs
Each tab can contain its own Stack navigator for drill-down navigation. Navigation state persists when switching between tabs.
app/(app)/
├── _layout.tsx # Tabs navigator
├── index.tsx # Home tab root
├── details/
│ ├── _layout.tsx # Stack inside Home tab
│ └── [id].tsx # Detail screen
├── profile.tsx # Profile tab root
└── admin/
├── _layout.tsx # Stack + role guard
└── index.tsx # Admin dashboardA user can navigate from the Home tab into a details screen, switch to the Profile tab, switch back to Home, and find their details screen still on the stack.
Handling deep links to protected routes
How deep linking works with route protection
Expo Router provides built-in deep linking. Every file in the app/ directory is automatically deep linkable. A link like myauthapp://profile opens the profile screen directly. A link like myauthapp://admin opens the admin section (if the user has access).
When an unauthenticated user taps a deep link to a protected route, the auth guard in the layout redirects them to sign-in. The catch: Expo Router does not automatically redirect back to the deep-linked route after authentication. You need to capture the intended destination and handle the redirect yourself.
Implementing post-authentication redirect
Capture the intended URL before redirecting to sign-in, then navigate there after successful authentication:
app/(auth)/sign-in.tsx (with deep link support)
import { useSignIn } from '@clerk/expo'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { useState } from 'react'
import { View, Text, TextInput, TouchableOpacity, StyleSheet } from 'react-native'
export default function SignInScreen() {
const { signIn, errors, fetchStatus } = useSignIn()
const router = useRouter()
const { returnTo } = useLocalSearchParams<{ returnTo?: string }>()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const handleSignIn = async () => {
const result = await signIn.password({ identifier: email, password })
if (result.status === 'complete') {
await signIn.finalize({
navigate: () => {
router.replace(returnTo || '/(app)')
},
})
}
}
return (
<View style={styles.container}>
<Text style={styles.title}>Sign in</Text>
<TextInput
value={email}
onChangeText={setEmail}
placeholder="Email"
autoCapitalize="none"
keyboardType="email-address"
style={styles.input}
/>
{errors?.fields?.identifier && (
<Text style={styles.error}>{errors.fields.identifier.message}</Text>
)}
<TextInput
value={password}
onChangeText={setPassword}
placeholder="Password"
secureTextEntry
style={styles.input}
/>
{errors?.fields?.password && (
<Text style={styles.error}>{errors.fields.password.message}</Text>
)}
<TouchableOpacity style={styles.button} onPress={handleSignIn}>
<Text style={styles.buttonText}>Sign In</Text>
</TouchableOpacity>
</View>
)
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 24, justifyContent: 'center' },
title: { fontSize: 24, fontWeight: 'bold', marginBottom: 24 },
input: {
borderWidth: 1,
borderColor: '#ccc',
borderRadius: 8,
padding: 12,
marginBottom: 12,
fontSize: 16,
},
button: {
backgroundColor: '#6C47FF',
padding: 16,
borderRadius: 8,
alignItems: 'center',
},
buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
error: { color: '#ef4444', marginBottom: 8, fontSize: 14 },
})Pass the intended destination as a query parameter when redirecting from the auth guard:
// In the (app) layout guard (alternative approach):
const pathname = usePathname()
if (!isSignedIn) {
return <Redirect href={`/(auth)/sign-in?returnTo=${pathname}`} />
}Configuring custom URL schemes
Configure your app's deep link scheme in app.json:
{
"expo": {
"scheme": "myauthapp"
}
}This enables links like myauthapp://dashboard to open your app directly. For production apps, configure Universal Links (iOS) and App Links (Android) for https:// scheme links that work even when the app isn't installed.
OAuth callback redirects use expo-web-browser to open the auth provider in an in-app browser and return to the app via the configured scheme.
An alternative pattern for deep links with Stack.Protected: present sign-in as a modal. When a deep link opens a protected screen, the background route is preserved behind the modal sign-in screen. After authentication, dismiss the modal and the user sees the originally linked content without any redirect logic. This works with Expo Router's modal presentation options (presentation: 'modal' or presentation: 'formSheet' on a Stack.Screen).
Managing authentication state during app startup
The startup timing problem
On cold start, the app needs to restore the session token from secure storage before it can determine if the user is signed in. This takes a few hundred milliseconds. Without proper handling, users see a flash of the sign-in screen before being redirected to the dashboard, or the dashboard briefly appears before redirecting to sign-in.
The complete root layout
Here's the full root layout bringing together everything from the previous sections: ClerkProvider, tokenCache, SplashScreen, and Stack.Protected.
app/_layout.tsx (final version)
import { ClerkProvider, useAuth } from '@clerk/expo'
import { tokenCache } from '@clerk/expo/token-cache'
import { Stack } from 'expo-router'
import * as SplashScreen from 'expo-splash-screen'
import { useEffect } from 'react'
// Must be called at module scope (calling inside a component may be too late)
SplashScreen.preventAutoHideAsync()
function RootNavigator() {
const { isSignedIn, isLoaded } = useAuth()
useEffect(() => {
if (isLoaded) {
// Auth state is resolved; safe to dismiss the splash screen
SplashScreen.hideAsync()
}
}, [isLoaded])
if (!isLoaded) {
// Keep the splash screen visible while Clerk restores the session
return null
}
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Protected guard={isSignedIn === true}>
<Stack.Screen name="(app)" />
</Stack.Protected>
<Stack.Protected guard={isSignedIn === false}>
<Stack.Screen name="(auth)" />
</Stack.Protected>
</Stack>
)
}
export default function RootLayout() {
return (
<ClerkProvider
publishableKey={process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!}
tokenCache={tokenCache}
>
<RootNavigator />
</ClerkProvider>
)
}By returning null from RootNavigator while isLoaded is false, the splash screen stays visible. Once Clerk restores the session, isLoaded flips to true, the navigator renders with the correct guards, and hideAsync() dismisses the splash screen. The user never sees the wrong screen.
Token persistence and offline support
Clerk's tokenCache handles persistence automatically. Session tokens are encrypted and stored on-device (iOS Keychain, Android Keystore). On restart, Clerk restores the session without requiring the user to sign in again.
For offline support, import resourceCache and pass it as an experimental prop:
import { resourceCache } from '@clerk/expo/resource-cache'
;<ClerkProvider
publishableKey={process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!}
tokenCache={tokenCache}
__experimental_resourceCache={resourceCache}
>
<RootNavigator />
</ClerkProvider>When enabled, resourceCache persists three categories of data to secure storage:
- Environment configuration: authentication strategies, display settings, organization settings, and feature flags for your Clerk instance
- Client state: active sessions, user data (email addresses, phone numbers, external accounts), and current sign-in/sign-up state
- Session JWT: the last active session token, returned by
getToken()when the network is unavailable
This means the app can rehydrate cached client state for offline rendering and role checks, and provide a cached token for fallback via getToken(). It does not enable authenticated remote API requests while disconnected, but you can use the cached JWT once network access is available again. The cached JWT may be expired, so your backend should handle token expiry gracefully.
The __experimental_ prefix is on the prop name only; the import path (@clerk/expo/resource-cache) is stable. The resource cache is available on iOS and Android only (not Expo Web). When resourceCache is enabled, Clerk automatically surfaces network errors via isClerkRuntimeError() with err.code === 'network_error' instead of silently swallowing them, enabling custom offline error handling.
Clerk's session tokens have a 60-second default lifetime and refresh automatically approximately every 50 seconds. This happens in the background with no action needed from your code. If a token refresh fails (for example, during a network outage) and resourceCache is enabled, getToken() returns the cached token. Without resourceCache, a failed refresh causes isSignedIn to eventually flip to false when the token expires, triggering the route guards.
The session itself (not the token) has a configurable lifetime defaulting to 7 days with a rolling inactivity timeout. As long as the user opens the app within that window, they stay signed in.
Common mistakes and gotchas
1. Redirecting before auth state loads
Checking isSignedIn without first checking isLoaded causes premature redirects. On app startup, isSignedIn is undefined until Clerk restores the session.
// ✅ Correct: gate on isLoaded first
const { isLoaded, isSignedIn } = useAuth()
if (!isLoaded) return null
if (!isSignedIn) return <Redirect href="/(auth)/sign-in" />Never skip the isLoaded check. Without it, every cold start redirects to sign-in, even for authenticated users. This is the single most common bug in Expo Router auth implementations.
2. Using hooks outside ClerkProvider
useAuth(), useUser(), useSignIn(), and all other Clerk hooks must be called inside a component wrapped by ClerkProvider. If you call them outside the provider, you'll get a runtime error about missing context. Place ClerkProvider in the root layout so all routes have access.
// ✅ Correct: hooks called in a child of ClerkProvider
export default function RootLayout() {
return (
<ClerkProvider publishableKey={key} tokenCache={tokenCache}>
<RootNavigator /> {/* useAuth() is safe here */}
</ClerkProvider>
)
}3. Flash of wrong screen
Rendering route content before auth state resolves causes a visible flash. Return null or a loading indicator while isLoaded is false.
// ✅ Correct: show nothing until auth is resolved
if (!isLoaded) return nullPaired with SplashScreen.preventAutoHideAsync(), this keeps the splash screen visible until the correct route is determined.
4. Navigation stack pollution after sign-out
After signing out, the user can press back and return to protected screens if the navigation stack isn't cleaned up.
// ✅ Correct: use replace for auth transitions
router.replace('/(auth)/sign-in')With Stack.Protected, this is handled automatically. When isSignedIn changes, the guard removes protected screens from the history.
5. Expo Go limitations
Not everything works in Expo Go. Features that require a development build:
- Native OAuth (Google Sign-In, Apple Sign-In)
- Native Clerk components (
AuthView,UserButton,UserProfileView) - Passkeys
- API routes (
+api.tsfiles)
JavaScript-only sign-in/sign-up flows and browser-based OAuth work in Expo Go. Plan your development environment around the features you need.
6. Rendering views before Slot in the root layout
Never conditionally render content before <Slot /> or <Stack> in the root layout. This prevents the navigator from mounting and causes a "Navigation object not initialized" runtime error.
// ❌ Wrong: rendering before the navigator
export default function RootLayout() {
const { isLoaded } = useAuth()
if (!isLoaded) return <LoadingScreen /> // blocks Slot from mounting
return <Slot />
}
// ✅ Correct: move auth logic to a child component
export default function RootLayout() {
return (
<ClerkProvider publishableKey={key} tokenCache={tokenCache}>
<RootNavigator /> {/* auth checks happen here */}
</ClerkProvider>
)
}7. Conditional hook calls
React hooks can't be called conditionally. This applies to useAuth(), useUser(), and all Clerk hooks.
// ❌ Wrong: conditional hook call
if (showProfile) {
const { user } = useUser()
}
// ✅ Correct: always call the hook, use the value conditionally
const { user } = useUser()
if (showProfile && user) {
// render profile
}Testing auth flows
For component-level testing of route protection and navigation, use expo-router/testing-library (provides renderRouter extending @testing-library/react-native). For mobile E2E testing of full auth flows, Maestro is the recommended tool. Note that @clerk/testing is designed for web E2E testing only (Playwright/Cypress) and doesn't support React Native or Expo.
Summary
Protecting routes in a mobile application requires careful orchestration between navigation state and authentication state. By combining Expo Router's file-based routing and layout guards with Clerk's authentication context, you can build secure, resilient navigation flows.
Whether you are implementing simple public/private route groups or complex role-based access controls across nested tab navigators, the key principles remain the same: always wait for the authentication state to load before redirecting, use layout-level guards for route protection, and ensure your backend validates all tokens. With these patterns in place, your Expo application is ready for production.
Frequently asked questions
How do I protect a specific tab in a Tab navigator?
To protect a specific tab, you can conditionally render its href based on the user's authentication state or role. Setting href to null hides the tab from the navigation bar. You should also ensure the layout guard for that specific route enforces the same access rules to prevent direct access via deep links.
Does Stack.Protected work with deep links?
Yes, Stack.Protected intercepts deep links to protected routes. If an unauthenticated user opens a deep link to a protected screen, the guard will redirect them to the anchor route (typically the sign-in screen). To redirect them back to their intended destination after they sign in, you need to capture the original URL and handle the post-authentication redirect manually.
Can users access protected screens while offline?
If you enable Clerk's experimental resourceCache, the application can read the cached session token and user state while offline. This allows users to view protected screens and make authenticated API requests (using the cached JWT) without an active network connection, provided their session hasn't expired.
In this series
- How to Protect Routes in Expo Router with Clerk
- How to Protect Routes in Expo Router with Clerk - Part 2 (you are here)