Skip to main content
Articles

How to Set Up Clerk Authentication with Expo Router

Author: Roy Anger
Published:

Setting up authentication in a React Native app with Expo Router requires installing @clerk/expo and expo-secure-store, wrapping your app in ClerkProvider in the root layout, and using useAuth() with <Redirect> in route group layouts to guard authenticated screens. Clerk's Core 3 SDK provides hooks (useSignIn, useSignUp, useAuth) for building custom auth flows, along with native components (AuthView, UserButton) for minimal-code integration.

Multi-factor authentication is handled through the signIn.mfa.* methods after detecting the needs_second_factor status during sign-in. Social login uses useSignInWithGoogle and useSignInWithApple for native platform flows, and useSSO for browser-based providers like GitHub. This guide walks through every step, from project scaffolding to production best practices, with complete code samples for each feature.

For quick setup, see the Clerk Expo quickstart and the Expo Router authentication docs.

Prerequisites

Before starting, confirm you have the following:

  • Node.js 20.9.0+ (LTS)
  • A Clerk account and a publishable key from the Clerk Dashboard
  • Basic familiarity with React and React Native
  • Expo CLI (use npx expo commands directly, no global install required)
  • A physical device or emulator (native Google/Apple sign-in and native components require a development build; basic email/password works in Expo Go)
  • Expo SDK 53 or later (@clerk/expo v3 requires SDK 53+)

What you will build

This tutorial produces a React Native app with Expo Router that includes:

  • A public homepage (always accessible)
  • Sign-in and sign-up screens with email/password
  • A protected user profile page
  • A protected settings page (demonstrating multiple protected routes)
  • A UserButton component
  • MFA verification during sign-in (TOTP and SMS)
  • Native Google sign-in (iOS + Android)
  • Native Apple sign-in (iOS only)
  • Browser-based GitHub sign-in via SSO

The final file structure looks like this:

app/
  _layout.tsx          (root layout with ClerkProvider + Slot)
  index.tsx            (public homepage)
  (auth)/
    _layout.tsx        (auth group layout with useAuth redirect)
    sign-in.tsx
    sign-up.tsx
  (home)/
    _layout.tsx        (protected group layout with useAuth redirect)
    profile.tsx
    settings.tsx

Understanding authentication in React Native with Expo Router

The challenge of mobile authentication

Authentication in React Native is more complex than web authentication for several reasons. Mobile apps cannot rely on HTTP-only cookies for session storage. Instead, tokens must be stored in platform-specific secure storage: iOS Keychain Services or Android Keystore-encrypted SharedPreferences. Sessions must persist across app restarts and crashes, which requires careful token lifecycle management.

Native OAuth flows add another layer of complexity. Developers must choose between system browser redirects, in-app webviews, and native SDK integrations. Each approach has different security characteristics and platform requirements. URL schemes, deep linking, App Links (Android), and Universal Links (iOS) all behave differently, and misconfiguration leads to silent failures or security vulnerabilities.

The deprecated auth.expo.io proxy (CVE-2023-28131) illustrates the risks of taking shortcuts with mobile auth. That proxy was widely used for OAuth in Expo apps, but a vulnerability allowed attackers to steal access tokens. Modern implementations must avoid this proxy entirely.

How Expo Router's file-based routing works with authentication

Expo Router uses a file-based routing system where files in the app/ directory become routes automatically. This model maps cleanly to authentication patterns through two key features: route groups and layout routes.

Route groups are directories wrapped in parentheses, like (auth) and (home). They organize routes without affecting URL paths. A file at app/(auth)/sign-in.tsx renders at the /sign-in path, not /(auth)/sign-in. This makes them ideal for separating authenticated and unauthenticated areas of the app.

Layout routes (_layout.tsx) wrap child routes and define navigators (Stack, Tabs, Slot). When a layout file uses useAuth() to check authentication state and renders a <Redirect> component conditionally, it creates a declarative auth guard. All routes within that group inherit the protection logic.

The combination of route groups and layout redirects replaces the need for manual navigation stack manipulation. Every route is also automatically deep-linkable, which means auth guards evaluate on deep link attempts as well.

How Clerk handles authentication in Expo

The @clerk/expo SDK (Core 3) uses a hybrid authentication model. A Client Token (long-lived, stored on the FAPI domain) establishes the device's identity with Clerk. A Session Token (60-second lifetime) authorizes requests to your application's backend. The SDK refreshes the session token every 50 seconds, proactively, before the 60-second expiry. See How Clerk Works for a detailed overview of this architecture.

Token caching uses expo-secure-store through the @clerk/expo/token-cache module. On iOS, tokens are stored in Keychain Services, which persists data across app reinstalls (as long as the bundle ID stays the same). On Android, tokens are stored in SharedPreferences encrypted with Keystore, and this data is deleted on app uninstall.

Clerk offers three integration tiers for Expo:

  1. JS-only custom UI: Build forms with useSignIn, useSignUp, and other hooks. Works in Expo Go. Maximum flexibility.
  2. JS + native sign-in: Custom forms combined with native OAuth buttons (useSignInWithGoogle, useSignInWithApple). Requires a development build.
  3. Native components: Prebuilt AuthView, UserButton, and UserProfileView components that render using SwiftUI (iOS) and Jetpack Compose (Android). Requires a development build. Currently in Beta.

The ClerkProvider component wraps the application and manages the session lifecycle, token refresh, and authentication state for all child components.

Setting up the project

Scaffolding a new Expo application

Create a new Expo project with TypeScript:

npx create-expo-app@latest clerk-expo-tutorial

This generates an SDK 54 project by default. The tutorial works with SDK 53 or later.

Navigate into the project directory:

cd clerk-expo-tutorial

Installing Clerk and dependencies

Install the core packages required for Clerk authentication:

npx expo install @clerk/expo expo-secure-store

For the full feature set (native OAuth, social login, development builds), install these additional packages:

npx expo install expo-crypto expo-apple-authentication expo-auth-session expo-web-browser expo-dev-client

Here is what each package provides:

  • @clerk/expo: Clerk's SDK for React Native with Expo, including hooks, components, and session management
  • expo-secure-store: Encrypted key-value storage using iOS Keychain and Android Keystore
  • expo-crypto: Cryptographic operations required by native Google and Apple sign-in
  • expo-apple-authentication: Native Apple Sign in with Apple API bindings (iOS only)
  • expo-auth-session: Browser-based OAuth 2.0 flow management for providers like GitHub
  • expo-web-browser: Opens system browser for authentication (Chrome Custom Tabs on Android, SFSafariViewController on iOS)
  • expo-dev-client: Enables development builds with custom native modules

Configuring environment variables

Create a .env file in the project root with your Clerk publishable key and (optionally) Google OAuth credentials:

EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_your-key-here

# Google OAuth (required for native Google sign-in)
EXPO_PUBLIC_CLERK_GOOGLE_WEB_CLIENT_ID=your-web-client-id
EXPO_PUBLIC_CLERK_GOOGLE_IOS_CLIENT_ID=your-ios-client-id
EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME=com.googleusercontent.apps.your-ios-client-id
EXPO_PUBLIC_CLERK_GOOGLE_ANDROID_CLIENT_ID=your-android-client-id

Keys prefixed with pk_test_ are for development instances and enable testing mode. Keys prefixed with pk_live_ are for production instances. Use test keys during development and switch to live keys for production deployments. Add .env to your .gitignore file to prevent committing secrets.

Configuring app.json plugins

Add the required Expo plugins to your app.json:

{
  "expo": {
    "plugins": [
      "expo-secure-store",
      [
        "@clerk/expo",
        {
          "appleSignIn": true
        }
      ],
      "expo-apple-authentication"
    ]
  }
}

The @clerk/expo plugin configures native modules for Clerk. Setting appleSignIn: true adds the Sign in with Apple entitlement to your iOS build. The expo-apple-authentication plugin registers the native Apple Authentication module.

Expo Go vs. development builds

Expo Go is a prebuilt client app for rapid development, but it cannot load custom native modules. A development build is a debug build of your app that includes all custom native code.

FeatureExpo GoDevelopment Build
Email/password
Browser-based OAuth
Native Google sign-in
Native Apple sign-in
Native components (AuthView)
Biometric login
PasskeysiOS 16+, Android 9+ physical device

For production-quality apps, use development builds. Create one with:

npx expo run:ios --device

Or use EAS Build for cloud-based builds:

eas build --profile development --platform ios

Configuring ClerkProvider

Adding ClerkProvider to the root layout

The root layout (app/_layout.tsx) wraps the entire application in ClerkProvider. This component must be the outermost wrapper, and it must render <Slot /> as its child to allow Expo Router to render the matched route.

Warning

The root layout component must NOT call useAuth() or any other Clerk hook directly. Hooks must be called from components nested inside the provider tree. All useAuth() calls belong in child route-group layouts (e.g., app/(auth)/_layout.tsx, app/(home)/_layout.tsx), which are rendered inside the provider via <Slot />.

import { ClerkProvider } from '@clerk/expo'
import { tokenCache } from '@clerk/expo/token-cache'
import { Slot } from 'expo-router'

const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!

if (!publishableKey) {
  throw new Error('EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY is not set.')
}

export default function RootLayout() {
  return (
    <ClerkProvider publishableKey={publishableKey} tokenCache={tokenCache}>
      <Slot />
    </ClerkProvider>
  )
}

The publishableKey prop must be passed explicitly. Environment variables accessed through process.env are inlined at build time by Metro, so this works in both development and production React Native builds.

Understanding token caching with expo-secure-store

The tokenCache import from @clerk/expo/token-cache wraps expo-secure-store automatically. No custom token cache implementation is needed.

On iOS, tokens are stored in Keychain Services. Keychain data persists across app reinstalls as long as the bundle ID remains the same. This means users can delete and reinstall the app without losing their session.

On Android, tokens are stored in SharedPreferences encrypted with Android Keystore. This data is deleted when the app is uninstalled because the encryption keys are bound to the app's installation. After reinstall, a new session is required.

The expo-secure-store config plugin automatically configures Android Auto Backup exclusion rules (via configureAndroidBackup, which defaults to true). This prevents restored SecureStore data from becoming unreadable after reinstall, since the encryption keys are deleted from Android Keystore on uninstall. If your app uses custom backup rules, set configureAndroidBackup: false in the expo-secure-store plugin config and manually add <exclude domain="sharedpref" path="SecureStore"/> to your backup rules XML.

Handling loading states with ClerkLoaded and ClerkLoading

Clerk needs to initialize before authentication state is available. Use ClerkLoaded and ClerkLoading to control rendering during this period:

import { ClerkProvider, ClerkLoaded, ClerkLoading } from '@clerk/expo'
import { tokenCache } from '@clerk/expo/token-cache'
import { Slot } from 'expo-router'
import { ActivityIndicator, View } from 'react-native'

const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!

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

ClerkLoaded renders its children only when Clerk's status is 'ready' or 'degraded'. ClerkLoading renders its children while Clerk is still initializing. Use these strategically around Clerk-dependent components rather than wrapping the entire app.

Building the authentication screens

Project structure for authentication routes

The recommended file structure separates public, auth, and protected routes:

app/
  _layout.tsx          (root layout: ClerkProvider + Slot)
  index.tsx            (public homepage, always accessible)
  (auth)/
    _layout.tsx        (redirects signed-in users away)
    sign-in.tsx
    sign-up.tsx
  (home)/
    _layout.tsx        (redirects unauthenticated users to sign-in)
    profile.tsx
    settings.tsx

The (auth) route group contains sign-in and sign-up screens. Its layout checks if the user is already signed in and redirects them to the home area. The (home) route group contains protected screens. Its layout checks if the user is signed in and redirects unauthenticated users to sign-in. Route group names in parentheses do not appear in URL paths.

Creating the sign-up screen

The sign-up screen uses useSignUp() from @clerk/expo with the Core 3 API. The flow has two phases: collecting credentials, then verifying the email address.

import { useSignUp } from '@clerk/expo'
import { Link, useRouter } from 'expo-router'
import type { Href } from 'expo-router'
import { useState } from 'react'
import { Text, TextInput, TouchableOpacity, View } from 'react-native'

export default function SignUpScreen() {
  const { signUp, errors, fetchStatus } = useSignUp()
  const router = useRouter()

  const [emailAddress, setEmailAddress] = useState('')
  const [password, setPassword] = useState('')
  const [code, setCode] = useState('')
  const [pendingVerification, setPendingVerification] = useState(false)

  const onSignUp = async () => {
    const { error } = await signUp.password({ emailAddress, password })

    if (!error) {
      await signUp.verifications.sendEmailCode()
      setPendingVerification(true)
    }
  }

  const onVerify = async () => {
    await signUp.verifications.verifyEmailCode({ code })

    if (signUp.status === 'complete') {
      await signUp.finalize()
    }
  }

  if (pendingVerification) {
    return (
      <View style={{ flex: 1, padding: 24, justifyContent: 'center' }}>
        <Text style={{ fontSize: 24, fontWeight: 'bold', marginBottom: 16 }}>
          Verify your email
        </Text>
        <TextInput
          value={code}
          onChangeText={setCode}
          placeholder="Enter verification code"
          keyboardType="number-pad"
          style={{
            borderWidth: 1,
            borderColor: '#ccc',
            borderRadius: 8,
            padding: 12,
            marginBottom: 16,
          }}
        />
        {errors?.fields?.code && (
          <Text style={{ color: 'red', marginBottom: 8 }}>{errors.fields.code[0]?.message}</Text>
        )}
        <TouchableOpacity
          onPress={onVerify}
          disabled={fetchStatus === 'fetching'}
          style={{
            backgroundColor: '#6C47FF',
            padding: 14,
            borderRadius: 8,
            alignItems: 'center',
          }}
        >
          <Text style={{ color: 'white', fontWeight: '600' }}>Verify</Text>
        </TouchableOpacity>
      </View>
    )
  }

  return (
    <View style={{ flex: 1, padding: 24, justifyContent: 'center' }}>
      <Text style={{ fontSize: 24, fontWeight: 'bold', marginBottom: 16 }}>Create an account</Text>
      <TextInput
        value={emailAddress}
        onChangeText={setEmailAddress}
        placeholder="Email"
        autoCapitalize="none"
        keyboardType="email-address"
        style={{
          borderWidth: 1,
          borderColor: '#ccc',
          borderRadius: 8,
          padding: 12,
          marginBottom: 12,
        }}
      />
      <TextInput
        value={password}
        onChangeText={setPassword}
        placeholder="Password"
        secureTextEntry
        style={{
          borderWidth: 1,
          borderColor: '#ccc',
          borderRadius: 8,
          padding: 12,
          marginBottom: 12,
        }}
      />
      {errors?.fields?.emailAddress && (
        <Text style={{ color: 'red', marginBottom: 8 }}>
          {errors.fields.emailAddress[0]?.message}
        </Text>
      )}
      {errors?.fields?.password && (
        <Text style={{ color: 'red', marginBottom: 8 }}>{errors.fields.password[0]?.message}</Text>
      )}
      <TouchableOpacity
        onPress={onSignUp}
        disabled={fetchStatus === 'fetching'}
        style={{
          backgroundColor: '#6C47FF',
          padding: 14,
          borderRadius: 8,
          alignItems: 'center',
          marginBottom: 16,
        }}
      >
        <Text style={{ color: 'white', fontWeight: '600' }}>Sign up</Text>
      </TouchableOpacity>
      <Link href="/(auth)/sign-in" style={{ textAlign: 'center', color: '#6C47FF' }}>
        Already have an account? Sign in
      </Link>
      <View nativeID="clerk-captcha" />
    </View>
  )
}

The <View nativeID="clerk-captcha" /> element at the bottom of the form is required for Clerk's bot protection. The errors.fields object provides field-level error messages from Clerk's validation.

Creating the sign-in screen

The sign-in screen uses useSignIn() with the Core 3 API. After password authentication, the flow checks signIn.status to determine if MFA is required.

import { useSignIn } from '@clerk/expo'
import { Link, useRouter } from 'expo-router'
import type { Href } from 'expo-router'
import { useState } from 'react'
import { Text, TextInput, TouchableOpacity, View } from 'react-native'

export default function SignInScreen() {
  const { signIn, errors, fetchStatus } = useSignIn()
  const router = useRouter()

  const [emailAddress, setEmailAddress] = useState('')
  const [password, setPassword] = useState('')
  const [mfaRequired, setMfaRequired] = useState(false)
  const [mfaCode, setMfaCode] = useState('')

  const onSignIn = async () => {
    const { error } = await signIn.password({ emailAddress, password })

    if (error) return

    if (signIn.status === 'complete') {
      await signIn.finalize()
    } else if (signIn.status === 'needs_second_factor') {
      setMfaRequired(true)
    }
  }

  const onVerifyMfa = async () => {
    await signIn.mfa.verifyTOTP({ code: mfaCode })

    if (signIn.status === 'complete') {
      await signIn.finalize()
    }
  }

  if (mfaRequired) {
    return (
      <View style={{ flex: 1, padding: 24, justifyContent: 'center' }}>
        <Text style={{ fontSize: 24, fontWeight: 'bold', marginBottom: 16 }}>
          Two-factor authentication
        </Text>
        <Text style={{ marginBottom: 16, color: '#666' }}>
          Enter the code from your authenticator app
        </Text>
        <TextInput
          value={mfaCode}
          onChangeText={setMfaCode}
          placeholder="Enter MFA code"
          keyboardType="number-pad"
          style={{
            borderWidth: 1,
            borderColor: '#ccc',
            borderRadius: 8,
            padding: 12,
            marginBottom: 16,
          }}
        />
        <TouchableOpacity
          onPress={onVerifyMfa}
          disabled={fetchStatus === 'fetching'}
          style={{
            backgroundColor: '#6C47FF',
            padding: 14,
            borderRadius: 8,
            alignItems: 'center',
          }}
        >
          <Text style={{ color: 'white', fontWeight: '600' }}>Verify</Text>
        </TouchableOpacity>
      </View>
    )
  }

  return (
    <View style={{ flex: 1, padding: 24, justifyContent: 'center' }}>
      <Text style={{ fontSize: 24, fontWeight: 'bold', marginBottom: 16 }}>Sign in</Text>
      <TextInput
        value={emailAddress}
        onChangeText={setEmailAddress}
        placeholder="Email"
        autoCapitalize="none"
        keyboardType="email-address"
        style={{
          borderWidth: 1,
          borderColor: '#ccc',
          borderRadius: 8,
          padding: 12,
          marginBottom: 12,
        }}
      />
      <TextInput
        value={password}
        onChangeText={setPassword}
        placeholder="Password"
        secureTextEntry
        style={{
          borderWidth: 1,
          borderColor: '#ccc',
          borderRadius: 8,
          padding: 12,
          marginBottom: 12,
        }}
      />
      {errors?.fields?.identifier && (
        <Text style={{ color: 'red', marginBottom: 8 }}>
          {errors.fields.identifier[0]?.message}
        </Text>
      )}
      {errors?.fields?.password && (
        <Text style={{ color: 'red', marginBottom: 8 }}>{errors.fields.password[0]?.message}</Text>
      )}
      <TouchableOpacity
        onPress={onSignIn}
        disabled={fetchStatus === 'fetching'}
        style={{
          backgroundColor: '#6C47FF',
          padding: 14,
          borderRadius: 8,
          alignItems: 'center',
          marginBottom: 16,
        }}
      >
        <Text style={{ color: 'white', fontWeight: '600' }}>Sign in</Text>
      </TouchableOpacity>
      <Link href="/(auth)/sign-up" style={{ textAlign: 'center', color: '#6C47FF' }}>
        Don't have an account? Sign up
      </Link>
    </View>
  )
}

When signIn.status returns 'needs_second_factor', the component switches to the MFA verification form. This example shows TOTP verification. The full MFA section below covers SMS and backup code strategies as well.

Note

In Core 3, signIn.password() and signIn.finalize() replace the legacy signIn.create() + setActive() pattern. The errors object returned from useSignIn() provides structured field-level errors, so try/catch blocks are not needed for validation errors.

![NOTE] The Clerk quickstart shows signIn.finalize({ navigate: ({ session, decorateUrl }) => router.replace(decorateUrl('/')) }). The navigate callback is optional. This article omits it because the layout-based redirect pattern (useAuth + Redirect in group layouts) handles navigation automatically on auth state change. Both approaches are valid.

Adding sign-out functionality

Sign-out uses useAuth() from @clerk/expo:

import { useAuth } from '@clerk/expo'
import { TouchableOpacity, Text } from 'react-native'

export function SignOutButton() {
  const { signOut } = useAuth()

  return (
    <TouchableOpacity onPress={() => signOut()} style={{ padding: 8 }}>
      <Text style={{ color: '#6C47FF', fontWeight: '600' }}>Sign out</Text>
    </TouchableOpacity>
  )
}

After sign-out, the (home) group layout detects the auth state change via useAuth() and the <Redirect> component sends the user back to the sign-in screen automatically.

Protecting routes in Expo Router

Understanding route groups for auth state

The (auth) group contains screens for unauthenticated users: sign-in and sign-up. The (home) group contains screens for authenticated users: profile and settings. The root-level index.tsx is public and always accessible. Route groups do not create URL segments, so (auth)/sign-in.tsx renders at /sign-in and (home)/profile.tsx renders at /profile.

Protecting routes with useAuth() redirects in group layouts

Route protection lives in the group layout files, not in the root layout. Each group layout calls useAuth() to check authentication state and renders a <Redirect> component to send users to the appropriate area:

  • app/(home)/_layout.tsx: If not signed in, redirects to /(auth)/sign-in
  • app/(auth)/_layout.tsx: If signed in, redirects to /

Both layouts must handle the loading state by returning null (or a loading indicator) while isLoaded is false. This prevents a flash of the wrong content before Clerk finishes initializing.

Building the auth and home group layouts

The auth group layout redirects signed-in users away from the sign-in/sign-up screens:

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="/" />
  }

  return (
    <Stack
      screenOptions={{
        headerShown: true,
        headerTitle: '',
      }}
    />
  )
}

The home group layout protects authenticated screens and adds a sign-out button to the header:

import { useAuth } from '@clerk/expo'
import { Redirect, Stack } from 'expo-router'
import { SignOutButton } from '../../components/SignOutButton'

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

  if (!isLoaded) return null

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

  return (
    <Stack
      screenOptions={{
        headerRight: () => <SignOutButton />,
      }}
    />
  )
}

The <Redirect> component from expo-router replaces the current route in the navigation stack. When useAuth() detects a state change (sign-in or sign-out), the layout re-renders and the redirect fires. This is client-side only and does not replace server-side auth validation.

Creating the settings page

The settings page demonstrates that multiple protected routes work inside the (home) group without additional configuration:

import { useUser } from '@clerk/expo'
import { Text, View, Switch } from 'react-native'
import { useState } from 'react'

export default function SettingsScreen() {
  const { user } = useUser()
  const [notificationsEnabled, setNotificationsEnabled] = useState(true)

  return (
    <View style={{ flex: 1, padding: 24 }}>
      <Text style={{ fontSize: 24, fontWeight: 'bold', marginBottom: 24 }}>Settings</Text>
      <View
        style={{
          flexDirection: 'row',
          justifyContent: 'space-between',
          alignItems: 'center',
          paddingVertical: 12,
          borderBottomWidth: 1,
          borderBottomColor: '#eee',
        }}
      >
        <Text>Push notifications</Text>
        <Switch value={notificationsEnabled} onValueChange={setNotificationsEnabled} />
      </View>
      <View style={{ paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: '#eee' }}>
        <Text style={{ color: '#666' }}>Account</Text>
        <Text style={{ marginTop: 4 }}>{user?.primaryEmailAddress?.emailAddress}</Text>
      </View>
      <View style={{ paddingVertical: 12 }}>
        <Text style={{ color: '#666' }}>Member since</Text>
        <Text style={{ marginTop: 4 }}>{user?.createdAt?.toLocaleDateString()}</Text>
      </View>
    </View>
  )
}

Any new file added to the app/(home)/ directory is automatically protected by the auth guard in the home group layout.

Creating the public homepage

The public homepage uses Clerk's <Show> component for conditional rendering based on auth state:

import { Show } from '@clerk/expo'
import { Link } from 'expo-router'
import { Text, View } from 'react-native'

export default function HomePage() {
  return (
    <View style={{ flex: 1, padding: 24, justifyContent: 'center' }}>
      <Text style={{ fontSize: 28, fontWeight: 'bold', marginBottom: 16 }}>
        Welcome to Clerk + Expo Router
      </Text>

      <Show when="signed-in">
        <Text style={{ marginBottom: 16 }}>You are signed in.</Text>
        <Link href="/(home)/profile" style={{ color: '#6C47FF', fontSize: 16 }}>
          Go to your profile
        </Link>
      </Show>

      <Show when="signed-out">
        <Text style={{ marginBottom: 16 }}>Sign in to access your account.</Text>
        <Link href="/(auth)/sign-in" style={{ color: '#6C47FF', fontSize: 16 }}>
          Sign in
        </Link>
      </Show>
    </View>
  )
}

In Core 3, the <Show> component replaces the deprecated <SignedIn>, <SignedOut>, and <Protect> components.

Using the Show component for conditional rendering

The <Show> component supports several conditional rendering patterns:

import { Show } from '@clerk/expo'
import { Text } from 'react-native'

function ConditionalExamples() {
  return (
    <>
      {/* Auth state checks */}
      <Show when="signed-in">
        <Text>Visible to signed-in users</Text>
      </Show>

      {/* Role-based checks */}
      <Show when={{ role: 'org:admin' }}>
        <Text>Visible to organization admins</Text>
      </Show>

      {/* Permission-based checks (recommended over role-based) */}
      <Show when={{ permission: 'org:invoices:create' }}>
        <Text>Visible to users with invoice creation permission</Text>
      </Show>

      {/* Custom logic with the has() helper */}
      <Show when={(has) => has({ role: 'org:admin' }) || has({ permission: 'org:billing:manage' })}>
        <Text>Visible to admins or billing managers</Text>
      </Show>

      {/* Fallback content */}
      <Show when="signed-in" fallback={<Text>Please sign in</Text>}>
        <Text>Welcome back</Text>
      </Show>
    </>
  )
}

Warning

<Show> only visually hides content on the client. The component tree and any data it contains remain accessible in the app bundle. Protect sensitive data with server-side validation or by fetching it conditionally after verifying auth state.

Permission-based checks (when={{ permission: '...' }}) are recommended over role-based checks because they decouple UI logic from role-based access control configuration. Roles can change, but permissions remain stable.

User profile and user button

Displaying the UserButton component

The native UserButton component from @clerk/expo/native provides a prebuilt avatar that opens the user profile modal on tap. It requires a development build.

import { UserButton } from '@clerk/expo/native'
import { View } from 'react-native'

function UserButtonExample() {
  return (
    <View style={{ width: 40, height: 40, borderRadius: 20, overflow: 'hidden' }}>
      <UserButton />
    </View>
  )
}

The native UserButton accepts no props. Sizing is controlled entirely by the parent container's width, height, borderRadius, and overflow styles. Tapping the button opens a native UserProfileView modal automatically.

For Expo Go compatibility, build a custom user button using useUser():

import { useUser } from '@clerk/expo'
import { Image, TouchableOpacity } from 'react-native'

function CustomUserButton({ onPress }: { onPress: () => void }) {
  const { user } = useUser()

  return (
    <TouchableOpacity onPress={onPress}>
      <Image source={{ uri: user?.imageUrl }} style={{ width: 40, height: 40, borderRadius: 20 }} />
    </TouchableOpacity>
  )
}

Building a user profile page

The profile page uses useUser() to display user information:

import { useUser } from '@clerk/expo'
import { Image, Text, View } from 'react-native'

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

  if (!user) return null

  return (
    <View style={{ flex: 1, padding: 24 }}>
      <View style={{ alignItems: 'center', marginBottom: 24 }}>
        <Image
          source={{ uri: user.imageUrl }}
          style={{ width: 80, height: 80, borderRadius: 40, marginBottom: 12 }}
        />
        <Text style={{ fontSize: 22, fontWeight: 'bold' }}>
          {user.firstName} {user.lastName}
        </Text>
        <Text style={{ color: '#666', marginTop: 4 }}>
          {user.primaryEmailAddress?.emailAddress}
        </Text>
      </View>

      <View style={{ borderTopWidth: 1, borderTopColor: '#eee', paddingTop: 16 }}>
        <Text style={{ fontWeight: '600', marginBottom: 8 }}>Account details</Text>
        <Text style={{ color: '#666', marginBottom: 4 }}>User ID: {user.id}</Text>
        <Text style={{ color: '#666', marginBottom: 4 }}>
          Created: {user.createdAt?.toLocaleDateString()}
        </Text>
        <Text style={{ color: '#666' }}>
          Last sign-in: {user.lastSignInAt?.toLocaleDateString()}
        </Text>
      </View>
    </View>
  )
}

For a native profile experience, use the UserProfileView component from @clerk/expo/native (requires a development build). This renders a native SwiftUI/Jetpack Compose profile management interface with built-in account settings, connected accounts, and security options.

Customizing user profile fields

Clerk provides three metadata fields on the user object for storing custom data:

  • user.publicMetadata: Readable from both frontend and backend; writable from backend only. Use for roles, feature flags, or other non-sensitive data that should be visible to the client.
  • user.unsafeMetadata: Readable and writable from the frontend. Use for user preferences or non-sensitive settings.
  • user.privateMetadata: Readable from backend only. Not accessible in client-side code.

To update user information programmatically:

import { useUser } from '@clerk/expo'

function UpdateProfile() {
  const { user } = useUser()

  const updateName = async () => {
    await user?.update({
      firstName: 'Jane',
      lastName: 'Doe',
    })
  }

  const updatePreferences = async () => {
    await user?.update({
      unsafeMetadata: {
        theme: 'dark',
        language: 'en',
      },
    })
  }
}

Adding multi-factor authentication

Understanding MFA strategies in Clerk

Clerk supports three MFA strategies in Expo:

  1. SMS verification codes: A one-time code sent via SMS to the user's registered phone number
  2. Authenticator apps (TOTP): Time-based one-time passwords generated by apps like Google Authenticator, Authy, or 1Password
  3. Backup codes: Single-use recovery codes generated when MFA is first enrolled

MFA must be enabled in the Clerk Dashboard under User & Authentication > Multi-factor. The "Require multi-factor authentication" toggle forces MFA enrollment for all users. Backup codes require at least one other MFA strategy to be enabled first. MFA is available on the Pro plan ($20/month billed annually as of 2026).

Handling MFA during sign-in

After calling signIn.password(), check signIn.status:

  • 'complete': First factor succeeded, no MFA required. Call signIn.finalize().
  • 'needs_second_factor': MFA is required. Present a verification form and use the signIn.mfa.* methods.

The signIn.supportedSecondFactors property lists the available MFA methods after the first factor is verified. This tells you which verification options to present to the user.

Building the MFA verification screen

This component handles all three MFA strategies and can be integrated into the sign-in flow:

import { useSignIn } from '@clerk/expo'
import { useState } from 'react'
import { Text, TextInput, TouchableOpacity, View } from 'react-native'

type MfaStrategy = 'totp' | 'phone_code' | 'backup_code'

export function MfaVerification() {
  const { signIn, errors, fetchStatus } = useSignIn()
  const [code, setCode] = useState('')
  const [strategy, setStrategy] = useState<MfaStrategy>('totp')

  const strategies: { key: MfaStrategy; label: string }[] = [
    { key: 'totp', label: 'Authenticator app' },
    { key: 'phone_code', label: 'SMS code' },
    { key: 'backup_code', label: 'Backup code' },
  ]

  const onSendSmsCode = async () => {
    await signIn.mfa.sendPhoneCode()
  }

  const onVerify = async () => {
    if (strategy === 'totp') {
      await signIn.mfa.verifyTOTP({ code })
    } else if (strategy === 'phone_code') {
      await signIn.mfa.verifyPhoneCode({ code })
    } else if (strategy === 'backup_code') {
      await signIn.mfa.verifyBackupCode({ code })
    }

    if (signIn.status === 'complete') {
      await signIn.finalize()
    }
  }

  return (
    <View style={{ flex: 1, padding: 24, justifyContent: 'center' }}>
      <Text style={{ fontSize: 24, fontWeight: 'bold', marginBottom: 8 }}>
        Two-factor authentication
      </Text>
      <Text style={{ color: '#666', marginBottom: 24 }}>
        Choose a verification method and enter your code.
      </Text>

      <View style={{ flexDirection: 'row', marginBottom: 16, gap: 8 }}>
        {strategies.map(({ key, label }) => (
          <TouchableOpacity
            key={key}
            onPress={() => {
              setStrategy(key)
              setCode('')
              if (key === 'phone_code') onSendSmsCode()
            }}
            style={{
              flex: 1,
              padding: 8,
              borderRadius: 8,
              borderWidth: 1,
              borderColor: strategy === key ? '#6C47FF' : '#ccc',
              backgroundColor: strategy === key ? '#F0ECFF' : 'white',
              alignItems: 'center',
            }}
          >
            <Text
              style={{
                fontSize: 12,
                color: strategy === key ? '#6C47FF' : '#666',
                fontWeight: strategy === key ? '600' : '400',
              }}
            >
              {label}
            </Text>
          </TouchableOpacity>
        ))}
      </View>

      <TextInput
        value={code}
        onChangeText={setCode}
        placeholder={strategy === 'backup_code' ? 'Enter backup code' : 'Enter verification code'}
        keyboardType={strategy === 'backup_code' ? 'default' : 'number-pad'}
        style={{
          borderWidth: 1,
          borderColor: '#ccc',
          borderRadius: 8,
          padding: 12,
          marginBottom: 16,
        }}
      />

      {errors?.fields?.code && (
        <Text style={{ color: 'red', marginBottom: 8 }}>{errors.fields.code[0]?.message}</Text>
      )}

      <TouchableOpacity
        onPress={onVerify}
        disabled={fetchStatus === 'fetching'}
        style={{
          backgroundColor: '#6C47FF',
          padding: 14,
          borderRadius: 8,
          alignItems: 'center',
        }}
      >
        <Text style={{ color: 'white', fontWeight: '600' }}>Verify</Text>
      </TouchableOpacity>
    </View>
  )
}

For TOTP verification, no "send" step is needed because the code is generated by the authenticator app. For SMS, call signIn.mfa.sendPhoneCode() first to trigger the SMS delivery, then verify with signIn.mfa.verifyPhoneCode({ code }). Backup codes are single-use and validated with signIn.mfa.verifyBackupCode({ code }).

Note

For MFA enrollment (setting up TOTP for the first time from within your app), see the Clerk TOTP management guide. The enrollment flow involves user.createTOTP() to generate a QR code URI, user.verifyTOTP({ code }) to confirm setup, and user.createBackupCode() to generate recovery codes.

Adding social login and OAuth

Native Google sign-in

Native Google sign-in uses useSignInWithGoogle from @clerk/expo/google. On Android, it uses Credential Manager (no browser popup). On iOS, it uses ASAuthorization for a native experience.

Setup requirements:

  1. Create three OAuth 2.0 credentials in Google Cloud Console: iOS Client ID, Android Client ID, and Web Client ID (the Web Client ID is required even for native mobile flows)
  2. Register SHA-1 fingerprints with Google Cloud Console for creating OAuth client IDs. Register SHA-256 fingerprints in the Clerk Dashboard for Android App Links verification (used for passkeys and deep linking). Both come from the same keystore via keytool -list -v but serve different purposes
  3. Add Client IDs to your .env and configure app.json
  4. Enable Google in the Clerk Dashboard under SSO connections
import { useSignInWithGoogle } from '@clerk/expo/google'
import { useRouter } from 'expo-router'
import { Platform, Text, TouchableOpacity } from 'react-native'

export function GoogleSignInButton() {
  const { startGoogleAuthenticationFlow } = useSignInWithGoogle()
  const router = useRouter()

  const onGoogleSignIn = async () => {
    try {
      const { createdSessionId, setActive } = await startGoogleAuthenticationFlow()

      if (createdSessionId && setActive) {
        await setActive({ session: createdSessionId })
        router.replace('/')
      }
    } catch (err: unknown) {
      const error = err as { code?: string | number }
      if (error.code === 'SIGN_IN_CANCELLED' || error.code === -5) {
        return
      }
      console.error('Google sign-in error:', err)
    }
  }

  if (Platform.OS === 'web') return null

  return (
    <TouchableOpacity
      onPress={onGoogleSignIn}
      style={{
        flexDirection: 'row',
        alignItems: 'center',
        justifyContent: 'center',
        backgroundColor: '#fff',
        borderWidth: 1,
        borderColor: '#ddd',
        borderRadius: 8,
        padding: 14,
      }}
    >
      <Text style={{ fontWeight: '600' }}>Continue with Google</Text>
    </TouchableOpacity>
  )
}

Note

The native Google/Apple sign-in hooks still use the older setActive() pattern rather than signIn.finalize(). This is the current documented behavior as of @clerk/expo v3.1.

The SIGN_IN_CANCELLED error (or code -5 on Android) occurs when the user dismisses the sign-in prompt. Handle this silently without showing an error message. Native Google sign-in requires a development build and does not work in Expo Go.

Native Apple sign-in

Native Apple sign-in uses useSignInWithApple from @clerk/expo/apple. This is iOS only.

Important

Apple App Store Guideline 4.8 requires that any app offering third-party social login must also offer Sign in with Apple on iOS.

Setup requirements:

  1. Register your native app in the Clerk Dashboard with your Team ID (App ID Prefix) and Bundle ID
  2. Enable Apple in the Clerk Dashboard under SSO connections
  3. Install expo-apple-authentication and expo-crypto
  4. Add expo-apple-authentication to the plugins in app.json
import { useSignInWithApple } from '@clerk/expo/apple'
import { useRouter } from 'expo-router'
import { Platform, Text, TouchableOpacity } from 'react-native'

export function AppleSignInButton() {
  const { startAppleAuthenticationFlow } = useSignInWithApple()
  const router = useRouter()

  const onAppleSignIn = async () => {
    try {
      const { createdSessionId, setActive } = await startAppleAuthenticationFlow()

      if (createdSessionId && setActive) {
        await setActive({ session: createdSessionId })
        router.replace('/')
      }
    } catch (err: unknown) {
      const error = err as { code?: string }
      if (error.code === 'ERR_REQUEST_CANCELED') {
        return
      }
      console.error('Apple sign-in error:', err)
    }
  }

  if (Platform.OS !== 'ios') return null

  return (
    <TouchableOpacity
      onPress={onAppleSignIn}
      style={{
        flexDirection: 'row',
        alignItems: 'center',
        justifyContent: 'center',
        backgroundColor: '#000',
        borderRadius: 8,
        padding: 14,
      }}
    >
      <Text style={{ color: '#fff', fontWeight: '600' }}>Continue with Apple</Text>
    </TouchableOpacity>
  )
}

Simulator support for Apple sign-in is limited. Test on a physical device for reliable behavior. The ERR_REQUEST_CANCELED error indicates the user dismissed the Apple sign-in prompt.

Browser-based SSO with useSSO

For providers without native SDKs (GitHub, Discord, and others), use the useSSO hook. This opens the system browser for authentication using Chrome Custom Tabs on Android and SFSafariViewController on iOS.

Note

useSSO() replaces the deprecated useOAuth() hook from Core 2. If migrating from older code, update all useOAuth calls to useSSO.

import { useSSO } from '@clerk/expo'
import { useRouter } from 'expo-router'
import { Text, TouchableOpacity } from 'react-native'
import * as WebBrowser from 'expo-web-browser'

WebBrowser.maybeCompleteAuthSession()

export function GitHubSignInButton() {
  const { startSSOFlow } = useSSO()
  const router = useRouter()

  const onGitHubSignIn = async () => {
    try {
      const { createdSessionId, setActive } = await startSSOFlow({
        strategy: 'oauth_github',
      })

      if (createdSessionId && setActive) {
        await setActive({ session: createdSessionId })
        router.replace('/')
      }
    } catch (err) {
      console.error('GitHub sign-in error:', err)
    }
  }

  return (
    <TouchableOpacity
      onPress={onGitHubSignIn}
      style={{
        flexDirection: 'row',
        alignItems: 'center',
        justifyContent: 'center',
        backgroundColor: '#24292e',
        borderRadius: 8,
        padding: 14,
      }}
    >
      <Text style={{ color: '#fff', fontWeight: '600' }}>Continue with GitHub</Text>
    </TouchableOpacity>
  )
}

The strategy parameter accepts any of the 30+ OAuth providers supported by Clerk (e.g., 'oauth_discord', 'oauth_slack', 'oauth_linkedin'). Browser-based SSO requires expo-auth-session and expo-web-browser, and it requires a development build.

Customization options

Styling Clerk components with the appearance prop

Clerk provides six prebuilt themes and 24 customization variables. Import themes from @clerk/ui/themes and pass them via the appearance prop on ClerkProvider:

import { ClerkProvider } from '@clerk/expo'
import { tokenCache } from '@clerk/expo/token-cache'
import { dark } from '@clerk/ui/themes'
import { Slot } from 'expo-router'

const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!

export default function RootLayout() {
  return (
    <ClerkProvider
      publishableKey={publishableKey}
      tokenCache={tokenCache}
      appearance={{
        theme: dark,
        variables: {
          colorPrimary: '#6C47FF',
          borderRadius: '0.5rem',
          fontFamily: 'Inter',
        },
      }}
    >
      <Slot />
    </ClerkProvider>
  )
}

Available prebuilt themes: default, simple, shadcn, dark, shadesOfPurple, neobrutalism. Themes can be stacked by passing an array: theme: [dark, neobrutalism].

Key customization variables include colorPrimary, colorBackground, colorDanger, fontFamily, fontSize, borderRadius, and spacing. The full variable list is available in the appearance variables reference.

Localization

Clerk supports 52+ locales through the @clerk/localizations package:

import { frFR } from '@clerk/localizations'
;<ClerkProvider localization={frFR} />

Localization is an experimental feature. It updates text in Clerk components but does not affect the hosted Account Portal. Custom string overrides are supported for fine-grained control.

Native components vs. custom UI trade-offs

AspectJS Custom UIJS + Native Sign-InNative Components
Expo Go
CustomizationFullFull + native buttonsLimited (ClerkTheme)
Code requiredMostModerateLeast (~5 lines)
OAuth experienceBrowser redirectNative (no browser)Native (automatic)
PasskeysVia @clerk/expo-passkeysVia @clerk/expo-passkeysBuilt-in
StatusBeta

For the least code possible, the native AuthView component handles the entire sign-in/sign-up flow:

import { useAuth } from '@clerk/expo'
import { AuthView } from '@clerk/expo/native'
import { Slot } from 'expo-router'

export default function AuthOrApp() {
  const { isSignedIn, isLoaded } = useAuth({ treatPendingAsSignedOut: false })

  if (!isLoaded) return null
  if (!isSignedIn) return <AuthView mode="signInOrUp" />
  return <Slot />
}

Start with the JS custom UI approach to understand the underlying API, then consider native components for production if styling flexibility is not a priority. Native components render using SwiftUI on iOS and Jetpack Compose on Android, providing a platform-native look and feel.

Note

AuthView passkey support requires domain association setup: iOS Associated Domains with webcredentials: entry, Android App Links with SHA-256 fingerprints. Without domain association, passkeys silently fail on physical devices. Verify current passkey support at /docs/reference/expo/passkeys */}

Best practices

Security considerations

  • Always use expo-secure-store for token caching. Never use AsyncStorage, which stores data in plaintext
  • Use separate Clerk instances with pk_test_ keys for development and pk_live_ keys for production
  • Register native apps in the Clerk Dashboard with the correct bundle identifiers and SHA fingerprints
  • Do not use the deprecated auth.expo.io proxy (CVE-2023-28131)
  • Use HTTPS for all API communication
  • Remember that <Show> only visually hides content. Protect sensitive data with server-side validation
  • Verify Android Auto Backup exclusion rules if using custom backup configuration

Performance optimization

  • Use <ClerkLoaded> and <ClerkLoading> strategically around Clerk-dependent components rather than wrapping the entire app
  • Let Clerk handle token refresh automatically (60-second lifetime, refreshed every 50 seconds). Do not implement manual token management
  • Consider the experimental __experimental_resourceCache from @clerk/expo/resource-cache for offline support
  • Use specific hooks (useUser(), useAuth()) instead of useClerk() to minimize unnecessary re-renders
  • Call SplashScreen.preventAutoHideAsync() from expo-splash-screen to prevent a flash of the wrong content during auth initialization

Code organization

  • Separate auth and protected routes into distinct route groups ((auth) and (home))
  • Keep auth configuration (ClerkProvider) in the root layout only
  • Place non-route files (components, hooks, utilities) outside the app/ directory. Expo Router treats every file in app/ as a route
  • Use environment-specific configuration with eas.json profiles for development, staging, and production builds
  • The signIn and signUp Future objects from useSignIn() and useSignUp() have unstable identity (they create a new reference on each flow state change). Prefer event-handler patterns over useEffect. When useEffect is necessary, include them in the dependency array and guard execution with a useRef(false) flag to prevent re-runs. See the OAuth custom flow example for this pattern

Testing strategies

  • Use pk_test_ keys for all development and testing
  • Test on physical devices for biometric authentication, native OAuth flows, and passkeys
  • Verify all auth state transitions: sign-in, sign-out, token refresh, MFA verification, and session expiry
  • Test deep linking to protected routes while unauthenticated to confirm redirect behavior
  • Test passkeys on physical devices only (iOS 16+, Android 9+). Passkeys do not work on Android emulators or in Expo Go

User experience

  • Show loading states during auth initialization and all authentication transitions
  • Provide clear error messages for failed authentication attempts using the structured errors.fields object from Core 3 hooks
  • Consider useLocalCredentials() for biometric login after initial password authentication (Beta, password-based sign-in only)
  • Handle expired sessions and network failures gracefully. Import isClerkRuntimeError from @clerk/expo and use isClerkRuntimeError(err) && err.code === 'network_error' to detect network errors specifically

Comparison: Clerk vs. other Expo authentication solutions

FeatureClerkFirebase AuthSupabase AuthAuth0
Works in Expo GoJS: YesJS SDK: YesLimited
Prebuilt RN UIAuthView (Beta)Browser-based
MFATOTP, SMS, backup codes (Pro)Typically requires Identity Platform upgradeTOTP free; phone paidPro MFA (Essentials+); none on free
Social login providers30+~1019+Unlimited
Token storageexpo-secure-store (1 import)ManualAsyncStoragecredentialsManager
Native OAuthGoogle + Apple hooksVia native SDKs onlyBrowser-basedBrowser-based
Passkeys (RN)Native + JS hooks
Free tier (as of 2026)50K MRU50K MAU (Spark plan)50K MAU (auto-pause)25K MAU
Paid starting price (as of 2026)$20/month billed annually (50K MRU incl.)Per-MAU (Identity Platform)$25/month$35/month (500 MAU)

Clerk is the only provider in this comparison that offers prebuilt native UI components for React Native, native passkey support in Expo, and dedicated OAuth hooks for Google and Apple sign-in. Its token management requires a single import (@clerk/expo/token-cache), compared to manual secure storage setup with other providers. Competitor pricing and feature details change frequently; check each provider's current pricing page for the latest information: Firebase Pricing, Supabase Pricing, Auth0 Pricing.