# 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](https://clerk.com/articles/how-to-protect-routes-in-expo-router-with-clerk.md).

_This is Part 2 of a two-part series on protecting routes in Expo Router with Clerk. In [Part 1](https://clerk.com/articles/how-to-protect-routes-in-expo-router-with-clerk.md), 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](https://clerk.com/docs/guides/organizations/overview.md), the recommended approach is storing roles in `publicMetadata` on the user object.

Clerk has three metadata types on the user object:

| Type              | Frontend   | Backend    | Use for roles?                   |
| ----------------- | ---------- | ---------- | -------------------------------- |
| `publicMetadata`  | Read       | Read/Write | Yes (secure, backend-controlled) |
| `privateMetadata` | No access  | Read/Write | No (not accessible client-side)  |
| `unsafeMetadata`  | Read/Write | Read/Write | No (users can modify it)         |

`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`**

```typescript
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 })
}
```

> Expo Router API routes (`+api.ts`) require a server environment (the local dev server during development, or EAS Hosting in production). They don't run inside Expo Go on-device. For local development and testing, set roles directly in the Clerk Dashboard: on the [**Users**](https://dashboard.clerk.com/~/users) page, select a user and edit their **Public metadata** to add `{"role": "admin"}`.

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**](https://dashboard.clerk.com/~/sessions) page. Under **Customize session token**, in the **Claims** editor, add the following and select **Save**:

```json
{
  "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`**

```typescript
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`**

```typescript
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`**

```typescript
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:

```typescript
// In app/(app)/_layout.tsx
;<Stack.Protected guard={role === 'admin'}>
  <Stack.Screen name="admin" />
</Stack.Protected>
```

> `has({ role: 'org:admin' })` does **not** check custom `publicMetadata` roles. It only works with Organization-based roles and requires an active Organization. For standalone apps using `publicMetadata`, compare `sessionClaims?.metadata?.role` directly.

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](https://clerk.com/glossary/multi-tenancy.md) apps, Clerk [Organizations](https://clerk.com/docs/guides/organizations/overview.md) provide built-in RBAC with the `has()` function, [custom roles](https://clerk.com/docs/guides/organizations/control-access/roles-and-permissions.md#custom-roles), and [custom permissions](https://clerk.com/docs/guides/organizations/control-access/roles-and-permissions.md#custom-permissions).

```typescript
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):

```typescript
<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`**

```typescript
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.

> Dynamically changing `href` between `null` and a path causes the tab navigator to remount. This is expected behavior.

### 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 dashboard
```

A 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)

```typescript
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:

```typescript
// 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`:

```json
{
  "expo": {
    "scheme": "myauthapp"
  }
}
```

This enables links like `myauthapp://dashboard` to open your app directly. For production apps, configure [Universal Links (iOS)](https://docs.expo.dev/linking/ios-universal-links/) and [App Links (Android)](https://docs.expo.dev/linking/android-app-links/) 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).

> The `+native-intent.tsx` file can intercept incoming deep links but has no access to auth context. Use `usePathname()` in layout files for URL-aware auth logic with full context.

## 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)

```typescript
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.

> Use `SplashScreen.setOptions({ duration: 200, fade: true })` for a smoother transition. The `fade` option is iOS only; on Android, the splash screen hides immediately regardless of this setting.

### 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:

```typescript
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.

> `resourceCache` enables **reading** cached state offline. Write operations like `signIn.password()` or `signUp.password()` still require a network connection and will throw a network error when offline.

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.

```typescript
// ✅ 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.

```typescript
// ✅ 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`.

```typescript
// ✅ Correct: show nothing until auth is resolved
if (!isLoaded) return null
```

Paired 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.

```typescript
// ✅ 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](https://docs.expo.dev/develop/development-builds/introduction/):

- Native OAuth (Google Sign-In, Apple Sign-In)
- Native Clerk components (`AuthView`, `UserButton`, `UserProfileView`)
- [Passkeys](https://clerk.com/docs/reference/expo/passkeys.md)
- API routes (`+api.ts` files)

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.

```typescript
// ❌ 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.

```typescript
// ❌ 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`](https://docs.expo.dev/router/reference/testing/) (provides `renderRouter` extending `@testing-library/react-native`). For mobile E2E testing of full auth flows, [Maestro](https://maestro.mobile.dev/) is the recommended tool. Note that [`@clerk/testing`](https://clerk.com/docs/guides/development/testing/overview.md) 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

1. [How to Protect Routes in Expo Router with Clerk](https://clerk.com/articles/how-to-protect-routes-in-expo-router-with-clerk.md)
2. **How to Protect Routes in Expo Router with Clerk - Part 2** (you are here)
