# How to Protect Routes in Expo Router with Clerk

To protect routes in Expo Router with Clerk, split your `app/` directory into `(auth)` and `(app)` route groups, wrap the root layout in `<ClerkProvider>` with Clerk's `tokenCache`, then add a layout-level guard in each group's `_layout.tsx` that checks `isSignedIn` from `useAuth()`. Use `<Redirect>` to send unauthenticated users to `/sign-in` and signed-in users away from auth screens. Always wait for `isLoaded === true` before redirecting to avoid the flash-of-wrong-screen problem. For role-based access, use Clerk's `has()` helper to check permissions before rendering protected content.

Expo SDK 53+ also offers `Stack.Protected`, a declarative alternative that automatically cleans up navigation history when a guard fails. This guide walks through both patterns while building a complete Expo Router app with Clerk [authentication](https://clerk.com/docs/guides/how-clerk-works/overview.md), covering public and private routes, [role-based access control](https://clerk.com/docs/guides/organizations/control-access/roles-and-permissions.md), feature-based [authorization](https://clerk.com/docs/guides/secure/authorization-checks.md), deep linking, and the most common pitfalls that trip up mobile developers.

### What you'll build

A complete Expo Router application with:

- Sign-up and sign-in screens using Clerk's Core 3 API
- A protected dashboard and user profile
- An admin-only section with role-based access control
- Tab navigation with conditional route visibility

**Tech stack**: Expo SDK 53+, Expo Router v5+, `@clerk/expo` v3+, TypeScript

### Prerequisites

- React and React Native fundamentals
- Node.js 18+ (20 LTS recommended)
- An [Expo development environment](https://docs.expo.dev/get-started/set-up-your-environment/)
- A [Clerk account](https://clerk.com/pricing) (free tier works)

> Expo SDK 55+ requires New Architecture (Legacy Architecture was removed). New Architecture has been the default since SDK 53.

## How Expo Router's file-based routing works

Expo Router maps files in the `app/` directory to navigation routes. Each file becomes a screen. Layout files (`_layout.tsx`) define the navigation structure for their directory and all child routes.

**Route groups** use parentheses to organize routes without affecting URLs. A file at `app/(app)/dashboard.tsx` produces the URL `/dashboard`, not `/(app)/dashboard`. This is the key feature that makes auth-based routing work: you can split your app into `(auth)` and `(app)` groups with different navigation rules.

```
app/
├── _layout.tsx              # Root layout: ClerkProvider + route guards
├── (auth)/
│   ├── _layout.tsx          # Stack navigator for auth screens
│   ├── sign-in.tsx          # Sign-in screen
│   └── sign-up.tsx          # Sign-up screen
└── (app)/
    ├── _layout.tsx          # Tab navigator with auth guard
    ├── index.tsx            # Dashboard (Home tab)
    ├── profile.tsx          # User profile tab
    └── admin/
        ├── _layout.tsx      # Role-based guard (admin only)
        └── index.tsx        # Admin dashboard
```

Expo Router supports **Stack** navigators for hierarchical push/pop navigation, **Tab** navigators for top-level sections, and nesting them together. The `<Redirect>` component handles declarative navigation, while `useRouter()` gives you programmatic control with `router.push()`, `router.replace()`, and `router.back()`.

## Setting up Clerk with Expo Router

### Install dependencies

Create a new Expo project and install the required packages.

```bash
npx create-expo-app@latest my-auth-app
cd my-auth-app
npx expo install @clerk/expo expo-secure-store
```

### Configure [environment variables](https://clerk.com/docs/guides/development/clerk-environment-variables.md)

Create a `.env` file in the project root with your Clerk publishable key.

```
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_your-key-here
```

Find your key in the [Clerk Dashboard](https://dashboard.clerk.com) under **API Keys**. You also need to **enable Native API** in the Clerk Dashboard, a commonly missed step.

### Configure ClerkProvider in the root layout

Add `ClerkProvider` to `app/_layout.tsx`. The `tokenCache` from `@clerk/expo/token-cache` encrypts and persists [session tokens](https://clerk.com/docs/guides/sessions/customize-session-tokens.md) on-device using `expo-secure-store` (iOS Keychain, Android Keystore). This means authentication state survives app restarts without requiring the user to sign in again.

**`app/_layout.tsx`**

```typescript
import { ClerkProvider } from '@clerk/expo'
import { tokenCache } from '@clerk/expo/token-cache'
import { Slot } from 'expo-router'
import * as SplashScreen from 'expo-splash-screen'

// Call at module scope (inside a component risks being too late)
SplashScreen.preventAutoHideAsync()

export default function RootLayout() {
  return (
    <ClerkProvider
      publishableKey={process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!}
      tokenCache={tokenCache}
    >
      <Slot />
    </ClerkProvider>
  )
}
```

Calling `SplashScreen.preventAutoHideAsync()` at **module scope** (outside the component function) is important. Calling it inside the component risks running after the splash screen has already been dismissed.

### Handle authentication loading state

Clerk's SDK needs time to restore the session from secure storage. During this window, `isLoaded` from `useAuth()` is `false`, and checking `isSignedIn` would give unreliable results. You can use the `ClerkLoaded` and `ClerkLoading` components as an alternative to checking `isLoaded` directly.

```typescript
import { ClerkLoaded, ClerkLoading, ClerkProvider } from '@clerk/expo'
import { ActivityIndicator, View } from 'react-native'

export default function RootLayout() {
  return (
    <ClerkProvider>
      <ClerkLoading>
        <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
          <ActivityIndicator size="large" />
        </View>
      </ClerkLoading>
      <ClerkLoaded>
        <Slot />
      </ClerkLoaded>
    </ClerkProvider>
  )
}
```

## Protecting routes: public vs private

### Route group architecture

Split your app into two route groups:

- `(auth)/` contains sign-in, sign-up, and other public screens
- `(app)/` contains dashboard, profile, admin, and all protected screens

Each group has its own `_layout.tsx` that enforces access rules. The parentheses mean these group names never appear in URLs.

### Stack.Protected: the recommended approach

`Stack.Protected` (available since Expo SDK 53 / Router v5) accepts a boolean `guard` prop. When `guard` is `false`, those screens become inaccessible and the user redirects to the **anchor route**, the nearest accessible screen. It also automatically cleans up navigation history when a screen becomes protected.

**`app/_layout.tsx`** (with route guards)

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

SplashScreen.preventAutoHideAsync()

function RootNavigator() {
  const { isSignedIn, isLoaded } = useAuth()

  if (!isLoaded) return null

  SplashScreen.hideAsync()

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

When `isSignedIn` is `true`, the `(app)` group is accessible and `(auth)` is blocked. When `isSignedIn` is `false`, the opposite applies. This dual-guard pattern handles both directions: preventing unauthenticated users from reaching protected screens, and preventing authenticated users from seeing login screens.

> `Stack.Protected` is client-side only. It controls navigation, not data access. Always validate authentication and authorization on your server.

> `Stack.Protected` has known platform-specific issues. On iOS, the protected screen may briefly appear before the guard redirects ([expo/expo #37305](https://github.com/expo/expo/issues/37305)). On web, routes with the same name in different protected groups can conflict ([expo/expo #37816](https://github.com/expo/expo/issues/37816)), and redirects may leave the wrong path in the browser address bar ([expo/expo #38387](https://github.com/expo/expo/issues/38387)). If you hit these, the `useAuth()` + `Redirect` approach described next is a reliable alternative.

### Alternative: useAuth + Redirect

For more control over redirect behavior, or for projects on older SDK versions, use `useAuth()` with the `<Redirect>` component in each group's layout.

In the `(app)` layout, redirect unauthenticated users to sign-in:

**`app/(app)/_layout.tsx`** (alternative approach)

```typescript
import { useAuth } from '@clerk/expo'
import { Redirect, Tabs } from 'expo-router'

export default function AppLayout() {
  const { isSignedIn, isLoaded } = useAuth()

  if (!isLoaded) return null

  if (!isSignedIn) {
    return <Redirect href="/(auth)/sign-in" />
  }

  return (
    <Tabs screenOptions={{ headerShown: true }}>
      <Tabs.Screen name="index" options={{ title: 'Home' }} />
      <Tabs.Screen name="profile" options={{ title: 'Profile' }} />
      <Tabs.Screen name="admin" options={{ title: 'Admin' }} />
    </Tabs>
  )
}
```

In the `(auth)` layout, redirect authenticated users to the dashboard:

**`app/(auth)/_layout.tsx`**

```typescript
import { useAuth } from '@clerk/expo'
import { Redirect, Stack } from 'expo-router'

export default function AuthLayout() {
  const { isSignedIn, isLoaded } = useAuth()

  if (!isLoaded) return null

  if (isSignedIn) {
    return <Redirect href="/(app)" />
  }

  return (
    <Stack screenOptions={{ headerShown: false }}>
      <Stack.Screen name="sign-in" />
      <Stack.Screen name="sign-up" />
    </Stack>
  )
}
```

The critical detail: **always check `isLoaded` before `isSignedIn`**. Skipping this check causes premature redirects on every cold start.

Here's how the two approaches compare side by side:

|                  | Stack.Protected          | useAuth + Redirect        |
| ---------------- | ------------------------ | ------------------------- |
| SDK requirement  | Expo SDK 53+             | Any version               |
| History cleanup  | Automatic                | Manual (`router.replace`) |
| Code location    | Root layout              | Each group layout         |
| Redirect control | Anchor route (automatic) | Full control over target  |
| Platform issues  | Known iOS/web bugs       | Stable across platforms   |

### Show component for conditional UI

The `<Show>` component from `@clerk/expo` conditionally renders UI elements based on authentication or authorization state. It handles rendering within a screen, **not route-level protection**. Always use layout guards for route protection.

```typescript
import { Show } from '@clerk/expo'

export default function Header() {
  return (
    <View>
      <Show when="signed-in">
        <UserAvatar />
      </Show>
      <Show when="signed-out">
        <SignInButton />
      </Show>
    </View>
  )
}
```

`Show` also supports authorization checks: `when={{ role: 'org:admin' }}`, `when={{ permission: 'org:posts:edit' }}`, `when={{ plan: 'premium' }}`, and `when={{ feature: 'premium_access' }}`. Plans and features use plain strings. Roles and permissions require an active Organization with the `org:` prefix.

The `treatPendingAsSignedOut` prop controls what happens during **pending sessions** (when a user has authenticated but hasn't completed required session tasks like selecting an organization). By default it's `true`, meaning pending users see the signed-out fallback content. Set it to `false` to show the signed-in content for pending users instead.

> `<Show>` only controls rendering on the client. It does not enforce access at the data level. For sensitive data, perform authorization checks on the server.

## Building the authentication screens

All examples use Clerk's **Core 3 Signal API**. Many existing tutorials online show the legacy `signIn.create()` / `setActive()` pattern, which is deprecated. The examples below use the current API with `signIn.password()` / `finalize()`.

The `finalize()` method accepts an optional `navigate` callback that controls where the user goes after authentication completes. Clerk passes `{ session, decorateUrl }` to the callback, where `session` is the newly created session and `decorateUrl` handles web-specific token management. In Expo apps, you can ignore these parameters and navigate directly.

### Sign-up screen

The sign-up flow has two phases: registration and email verification.

**`app/(auth)/sign-up.tsx`**

```typescript
import { useSignUp } from '@clerk/expo'
import { useRouter } from 'expo-router'
import { useState } from 'react'
import {
  View,
  Text,
  TextInput,
  TouchableOpacity,
  ActivityIndicator,
  StyleSheet,
} from 'react-native'

export default function SignUpScreen() {
  const { signUp, errors, fetchStatus } = useSignUp()
  const router = useRouter()
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [code, setCode] = useState('')
  const [pendingVerification, setPendingVerification] = useState(false)

  const handleSignUp = async () => {
    await signUp.password({ emailAddress: email, password })
    await signUp.verifications.sendEmailCode()
    setPendingVerification(true)
  }

  const handleVerification = async () => {
    await signUp.verifications.verifyEmailCode({ code })
    await signUp.finalize({ navigate: () => router.replace('/(app)') })
  }

  if (pendingVerification) {
    return (
      <View style={styles.container}>
        <Text style={styles.title}>Verify your email</Text>
        <TextInput
          value={code}
          onChangeText={setCode}
          placeholder="Enter verification code"
          keyboardType="number-pad"
          style={styles.input}
        />
        {errors?.fields?.code && <Text style={styles.error}>{errors.fields.code.message}</Text>}
        {fetchStatus === 'fetching' ? (
          <ActivityIndicator />
        ) : (
          <TouchableOpacity style={styles.button} onPress={handleVerification}>
            <Text style={styles.buttonText}>Verify</Text>
          </TouchableOpacity>
        )}
      </View>
    )
  }

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Create an account</Text>
      <TextInput
        value={email}
        onChangeText={setEmail}
        placeholder="Email"
        autoCapitalize="none"
        keyboardType="email-address"
        style={styles.input}
      />
      {errors?.fields?.emailAddress && (
        <Text style={styles.error}>{errors.fields.emailAddress.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>
      )}
      {fetchStatus === 'fetching' ? (
        <ActivityIndicator />
      ) : (
        <TouchableOpacity style={styles.button} onPress={handleSignUp}>
          <Text style={styles.buttonText}>Sign Up</Text>
        </TouchableOpacity>
      )}
      <TouchableOpacity onPress={() => router.push('/(auth)/sign-in')}>
        <Text style={styles.link}>Already have an account? 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 },
  link: { color: '#6C47FF', marginTop: 16, textAlign: 'center' },
})
```

The Core 3 API handles errors reactively through the `errors.fields` object. When `signUp.password()` encounters validation issues (like an invalid email or weak password), `errors.fields` updates automatically and the component re-renders with the error messages displayed. No try/catch needed for validation errors.

The `fetchStatus` value switches between `'idle'` and `'fetching'`, so you can show a loading indicator while the API call is in flight.

### Sign-in screen

**`app/(auth)/sign-in.tsx`**

```typescript
import { useSignIn } from '@clerk/expo'
import { useRouter } from 'expo-router'
import { useState } from 'react'
import {
  View,
  Text,
  TextInput,
  TouchableOpacity,
  ActivityIndicator,
  StyleSheet,
} from 'react-native'

export default function SignInScreen() {
  const { signIn, errors, fetchStatus } = useSignIn()
  const router = useRouter()
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')

  const handleSignIn = async () => {
    await signIn.password({ identifier: email, password })
    await signIn.finalize({ navigate: () => router.replace('/(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>
      )}
      {fetchStatus === 'fetching' ? (
        <ActivityIndicator />
      ) : (
        <TouchableOpacity style={styles.button} onPress={handleSignIn}>
          <Text style={styles.buttonText}>Sign In</Text>
        </TouchableOpacity>
      )}
      <TouchableOpacity onPress={() => router.push('/(auth)/sign-up')}>
        <Text style={styles.link}>Don't have an account? Sign up</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 },
  link: { color: '#6C47FF', marginTop: 16, textAlign: 'center' },
})
```

Using `router.replace()` in the `navigate` callback prevents the sign-in screen from remaining in the navigation stack. The callback receives `{ session, decorateUrl }` from Clerk, but in Expo apps you can navigate directly since `decorateUrl` is primarily for web cookie management. With `Stack.Protected`, `router.push()` is also safe because the guard automatically redirects authenticated users away from auth screens. Without route guards, `router.replace()` is the safer choice.

### Sign-out flow

**`components/SignOutButton.tsx`**

```typescript
import { useClerk } from '@clerk/expo'
import { useRouter } from 'expo-router'
import { TouchableOpacity, Text, StyleSheet } from 'react-native'

export function SignOutButton() {
  const { signOut } = useClerk()
  const router = useRouter()

  const handleSignOut = async () => {
    await signOut()
    router.replace('/(auth)/sign-in')
  }

  return (
    <TouchableOpacity style={styles.button} onPress={handleSignOut}>
      <Text style={styles.text}>Sign Out</Text>
    </TouchableOpacity>
  )
}

const styles = StyleSheet.create({
  button: { padding: 12 },
  text: { color: '#ef4444', fontSize: 16 },
})
```

With `Stack.Protected`, signing out triggers the guard change (`isSignedIn` flips to `false`), which automatically cleans up navigation history and redirects to the auth screens. The explicit `router.replace()` call acts as a fallback for setups that don't use `Stack.Protected`.

### [OAuth](https://clerk.com/docs/guides/configure/auth-strategies/oauth/overview.md) and social sign-in

For social [single sign-on](https://clerk.com/glossary/single-sign-on-sso.md), use the `useSSO()` hook (which replaces the deprecated `useOAuth()`):

```typescript
import { useSSO } from '@clerk/expo'

const { startSSOFlow } = useSSO()

const result = await startSSOFlow({ strategy: 'oauth_google' })

// SSO completion uses setActive(), not finalize()
if (result.createdSessionId) {
  await result.setActive({ session: result.createdSessionId })
}
```

Native OAuth (Google Sign-In, Apple Sign-In) and native Clerk components (`AuthView`, `UserButton`) require a [development build](https://docs.expo.dev/develop/development-builds/introduction/). Browser-based OAuth and the JavaScript-only flows shown above work in Expo Go. See the [full SSO documentation](https://clerk.com/docs/reference/expo/native-hooks/use-sso.md) for complete implementation details.

> The SSO flow uses `setActive({ session: createdSessionId })` to activate the session. This differs from the email/password flows, which use `finalize({ navigate })`. The `useSSO` hook uses legacy patterns internally. Don't try to use `finalize()` with SSO results.

## Building protected screens

### Dashboard

The dashboard sits behind the auth guard. Once the user is signed in, they land here.

**`app/(app)/index.tsx`**

```typescript
import { useAuth, useUser } from '@clerk/expo'
import { View, Text, StyleSheet } from 'react-native'
import { SignOutButton } from '../../components/SignOutButton'

export default function DashboardScreen() {
  const { userId, sessionId, getToken } = useAuth()
  const { user } = useUser()

  const fetchProtectedData = async () => {
    const token = await getToken()
    const response = await fetch('https://your-api.com/data', {
      headers: { Authorization: `Bearer ${token}` },
    })
    return response.json()
  }

  return (
    <View style={styles.container}>
      <Text style={styles.title}>
        Welcome, {user?.firstName || user?.primaryEmailAddress?.emailAddress}
      </Text>
      <Text style={styles.detail}>User ID: {userId}</Text>
      <Text style={styles.detail}>Session: {sessionId}</Text>
      <SignOutButton />
    </View>
  )
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 24 },
  title: { fontSize: 24, fontWeight: 'bold', marginBottom: 16 },
  detail: { fontSize: 14, color: '#666', marginBottom: 8 },
})
```

Use `getToken()` from `useAuth()` to attach Clerk session tokens to API requests. Your backend validates these tokens using Clerk's backend SDK to ensure only authenticated users access your API.

### User profile

**`app/(app)/profile.tsx`**

```typescript
import { useUser } from '@clerk/expo'
import { View, Text, Image, StyleSheet } from 'react-native'
import { SignOutButton } from '../../components/SignOutButton'

export default function ProfileScreen() {
  const { user } = useUser()

  return (
    <View style={styles.container}>
      {user?.imageUrl && <Image source={{ uri: user.imageUrl }} style={styles.avatar} />}
      <Text style={styles.name}>{user?.fullName}</Text>
      <Text style={styles.email}>{user?.primaryEmailAddress?.emailAddress}</Text>
      <SignOutButton />
    </View>
  )
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 24, alignItems: 'center' },
  avatar: { width: 100, height: 100, borderRadius: 50, marginBottom: 16 },
  name: { fontSize: 24, fontWeight: 'bold', marginBottom: 4 },
  email: { fontSize: 16, color: '#666', marginBottom: 16 },
})
```

For richer profile management, Clerk provides a native `UserProfileView` component from `@clerk/expo/native`. It renders a full profile editing UI using SwiftUI on iOS and Jetpack Compose on Android, but requires a development build.

### Admin dashboard

The admin dashboard is only accessible to users with the `admin` role. The layout guard (covered in the next section) handles the access control.

**`app/(app)/admin/index.tsx`**

```typescript
import { useAuth, useUser } from '@clerk/expo'
import { View, Text, StyleSheet } from 'react-native'

export default function AdminDashboardScreen() {
  const { sessionClaims } = useAuth()
  const { user } = useUser()
  const role = sessionClaims?.metadata?.role

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Admin Dashboard</Text>
      <Text style={styles.detail}>Signed in as {user?.primaryEmailAddress?.emailAddress}</Text>
      <Text style={styles.detail}>Role: {role}</Text>
      <Text style={styles.info}>
        This screen is protected by the admin layout guard. Only users with the admin role in their
        publicMetadata can reach this screen.
      </Text>
    </View>
  )
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 24 },
  title: { fontSize: 24, fontWeight: 'bold', marginBottom: 16 },
  detail: { fontSize: 14, color: '#666', marginBottom: 8 },
  info: { fontSize: 14, color: '#333', marginTop: 16, lineHeight: 22 },
})
```

The `sessionClaims` object gives you access to the custom claims you configured in the Clerk Dashboard. Since you added `publicMetadata` to the session token, the role is available at `sessionClaims?.metadata?.role` without any additional API calls.

## Role-based access control (RBAC)

### Setting up roles in Clerk

For apps that don't use Clerk [Organizations](https://clerk.com/docs/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 { 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](https://dashboard.clerk.com): navigate to **Users**, select a user, click **Public metadata**, then **Edit**, and 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, go to **Sessions** and click **Edit** on the claims editor. Add:

```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/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), and [custom permissions](https://clerk.com/docs/guides/organizations/control-access/roles-and-permissions.md).

```typescript
import { Show } from '@clerk/expo'
import { View } from 'react-native'

export default function RootLayout() {
  return (
    <View style={styles.container}>
      // 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 () => {
    await signIn.password({ identifier: email, password })
    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'

// Must be called at module scope (calling inside a component may be too late)
SplashScreen.preventAutoHideAsync()

function RootNavigator() {
  const { isSignedIn, isLoaded } = useAuth()

  if (!isLoaded) {
    // Keep the splash screen visible while Clerk restores the session
    return null
  }

  // Auth state is resolved; safe to dismiss the splash screen
  SplashScreen.hideAsync()

  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 render user information, check roles, and make authenticated API requests (using the cached JWT) even when offline. 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.

## Frequently asked questions

## FAQ

### How do I protect routes in Expo Router with Clerk?

Use route groups with layout-level auth guards. Organize your app into `(auth)` and `(app)` groups, each with their own `_layout.tsx`. Use `Stack.Protected` with a `guard` prop tied to `isSignedIn` from `useAuth()`, or use `<Redirect>` in each layout to direct users to the correct group.

### What is the difference between Stack.Protected and manual redirects?

`Stack.Protected` (Expo SDK 53+) declaratively marks screens as guarded and automatically handles navigation history cleanup when the guard condition changes. Manual redirects using `<Redirect>` give more control over the redirect target but require you to manage navigation state yourself, including using `router.replace()` to prevent back-navigation issues.

### How do I prevent a flash of the sign-in screen on app startup?

Keep the splash screen visible with `SplashScreen.preventAutoHideAsync()` (called at module scope) until `isLoaded` from `useAuth()` is `true`. Return `null` from your navigator while loading. Only call `SplashScreen.hideAsync()` after the auth state is resolved and the correct route group is ready to render.

### Can I use Clerk with Expo Go, or do I need a development build?

JavaScript-only sign-in/sign-up flows and browser-based OAuth work in Expo Go. Native OAuth (Google Sign-In, Apple Sign-In), native Clerk components (`AuthView`, `UserButton`, `UserProfileView`), passkeys, and API routes require a [development build](https://docs.expo.dev/develop/development-builds/introduction/).

### How does Clerk persist authentication across app restarts?

Clerk uses `expo-secure-store` via its built-in `tokenCache` to encrypt and store session tokens on-device (iOS Keychain, Android Keystore). On restart, the token is restored automatically without requiring the user to sign in again. Import `tokenCache` from `@clerk/expo/token-cache` and pass it to `ClerkProvider`.

### How do I implement role-based access control in Expo Router?

Store roles in Clerk's `publicMetadata` on the user object (set via the backend API), then customize the session token in the Clerk Dashboard to include `publicMetadata`. Read the role client-side via `useAuth().sessionClaims?.metadata?.role` and use it in layout guards. For B2B apps, Clerk [Organizations](https://clerk.com/docs/organizations/overview.md) provide built-in RBAC with the `has()` function.

### How do I handle deep links to protected routes?

Expo Router's auth guards redirect unauthenticated deep link requests to the sign-in screen. Capture the intended destination (via `usePathname()` or query parameters) before the redirect, then navigate there with `router.replace(returnTo)` after successful authentication.

### What happens if a user's role changes while they are using the app?

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 call `user.reload()` to refresh user data including metadata.

### How do I protect API calls from the mobile app, not just routes?

Use `getToken()` from `useAuth()` to get a session token and attach it as a Bearer token in the `Authorization` header of your API requests. On your backend, validate the token using Clerk's backend SDK (e.g., `clerkMiddleware()` for Express) to ensure only authenticated and authorized users access your endpoints.

### Should I use the Show component or layout guards for route protection?

Use layout guards (`useAuth()` + `<Redirect>` or `Stack.Protected`) for route-level protection, preventing access to entire screens. Use the `<Show>` component for conditional rendering of UI elements within a screen that should vary based on auth state, roles, or permissions. `<Show>` is for visual control; layout guards are for access control.
