# Expo Go vs Development Build?

Use Expo Go for basic email and password authentication during early development, but switch to a development build when you need native Google Sign-In, biometrics, or other features requiring native modules. Clerk supports three tiers of Expo integration: Expo Go for simple flows, development builds for native sign-in methods, and production builds for App Store and TestFlight distribution.

This guide walks through building a fully working Expo app with Clerk authentication — Google native sign-in, browser-based Google and GitHub OAuth, email OTP, protected routes, and production builds you can share via TestFlight. If you want to follow along with a working reference, check out the [Clerk Expo quickstart](https://clerk.com/docs/expo/getting-started/quickstart.md) and the [clerk-expo-quickstart repository](https://github.com/clerk/clerk-expo-quickstart).

> This tutorial builds a new app with `@clerk/expo` 3.0 ([Core 3](https://clerk.com/changelog/2026-03-03-core-3.md)). If you're upgrading an existing project from `@clerk/clerk-expo` (Core 2), see the [Core 3 upgrade guide](https://clerk.com/docs/guides/development/upgrading/upgrade-guides/core-3.md) for step-by-step migration instructions and breaking changes.

## Expo Go vs development builds: what actually matters for authentication

Developers searching for "Expo Go vs development build" have usually just hit a wall with [OAuth](https://clerk.com/docs/guides/configure/auth-strategies/social-connections/overview.md) redirects. Here's what's actually going on and why the real answer involves three approaches, not two.

### What Expo Go can and can't do

Expo Go is a pre-built native app that runs your JavaScript bundle. It's great for rapid prototyping, but it has limitations that matter for auth.

The big one: Expo Go can't register custom URL schemes. When Google's OAuth flow tries to redirect back to your app via `myapp://callback`, there's no `myapp://` scheme registered. The redirect fails silently or lands nowhere. Expo Go also can't load custom native modules, which rules out native Google Sign-In (it uses a TurboModule under the hood). Deep links in Expo Go use the `/--/` prefix format, which doesn't work with standard OAuth callback patterns.

What does work in Expo Go: email/password with custom sign-in forms, basic session management with `useAuth()`, the `Show` component for conditional rendering, and any JavaScript-only auth flow that doesn't need native modules or custom URL schemes.

### Why development builds solve the OAuth problem

A development build is your own native app with a development experience bolted on. You compile the native code yourself (or let EAS Build do it), which means custom URL schemes, native modules, and deep linking all work.

Under the hood, `expo-dev-client` gives you the dev menu, hot reload, and bundle server switching that Expo Go provides, but inside your app with your native configuration. The fundamental distinction is that Expo Go uses Expo's native bundle while a development build uses yours.

Continuous Native Generation (CNG) via `npx expo prebuild` generates the `ios/` and `android/` directories from your `app.json` config and plugins. Config plugins like `@clerk/expo` automatically wire up native entitlements for features like Apple Sign-In and native Google Sign-In.

### The three-tier reality

Clerk's Expo SDK offers three approaches, not two:

| Approach                                                                                  | Works in Expo Go? | Dev build required? | What you get                                                                                            |
| ----------------------------------------------------------------------------------------- | :---------------: | :-----------------: | ------------------------------------------------------------------------------------------------------- |
| **JavaScript-only**                                                                       |        Yes        |          No         | Custom sign-in/sign-up UI with email/password. Full control, most code.                                 |
| **JS + native sign-in**                                                                   |         No        |         Yes         | Custom UI + native Google/Apple sign-in via OS-level account picker. Less code than full custom.        |
| **[Native components](https://clerk.com/changelog/2026-03-09-expo-native-components.md)** |         No        |         Yes         | Pre-built AuthView, UserButton, UserProfileView. SwiftUI (iOS) + Jetpack Compose (Android). Least code. |

This article builds with the **native components** approach (least code, best UX) and also shows the **browser-based OAuth** approach for GitHub (since native sign-in isn't available for all providers). If you're just prototyping email/password auth, Expo Go works fine. Switch to a development build when you add OAuth or native sign-in.

## Setting up the project

### Prerequisites

Before you start, make sure you have:

- **Node.js 20.9.0+** (Clerk Core 3 requirement)
- **Expo CLI** (`npx expo`)
- **EAS CLI** (`npm install -g eas-cli`) for production builds later
- **Xcode** (iOS) or **Android Studio** (Android) for local development builds
- **A Clerk account** (free tier supports 50,000 monthly retained users and unlimited applications)
- **[Apple Developer Program](https://developer.apple.com/programs/)** ($99/year) if you want to test on physical iOS devices or distribute via TestFlight. Simulator builds work without the paid account.

This tutorial targets [Expo SDK 55](https://expo.dev/changelog/sdk-55) (current stable, React Native 0.83). The minimum requirement for Clerk Core 3 is SDK 53. At the time of writing, the App Store and Play Store versions of Expo Go run SDK 54. You can install SDK 55 Expo Go via CLI on Android or use the TestFlight beta on iOS, but development builds are the most reliable path for SDK 55 and are required for the OAuth and native features covered here.

### Creating the Expo project

Create a new project with Expo Router for file-based routing:

```bash
npx create-expo-app@latest clerk-auth-demo
cd clerk-auth-demo
```

### Installing Clerk and dependencies

Install the required packages:

```bash
npx expo install @clerk/expo expo-secure-store expo-web-browser expo-auth-session expo-crypto
```

Here's what each package does:

- `@clerk/expo`: The Clerk SDK (Core 3). This package was renamed from `@clerk/clerk-expo` in Core 3.
- `expo-secure-store`: Encrypted token storage using iOS Keychain and Android Keystore.
- `expo-web-browser`: Opens an in-app browser for browser-based OAuth flows.
- `expo-auth-session`: Generates OAuth redirect URIs with the correct scheme.
- `expo-crypto`: Peer dependency required for the `useSignInWithGoogle()` hook. Not needed if you only use AuthView.

Next, configure the `@clerk/expo` plugin and a custom URL scheme in `app.json`:

```json
{
  "expo": {
    "plugins": ["@clerk/expo"],
    "scheme": "clerk-auth-demo"
  }
}
```

The `@clerk/expo` config plugin automatically sets up Apple Sign-In entitlements and the native Google Sign-In TurboModule during prebuild. The `scheme` field registers a custom URL scheme for OAuth redirects.

### Creating your first development build

Run the following command to create a local development build:

```bash
npx expo run:ios
```

For Android, use `npx expo run:android` instead. What happens under the hood: Expo runs `prebuild` to generate native directories from your `app.json` config and plugins, compiles the native code, and installs the app on your simulator or device. This is a local build. Later, you'll use EAS for cloud builds and production.

## Configuring Clerk

### Setting up the Clerk Dashboard

Create a new application in the [Clerk Dashboard](https://dashboard.clerk.com). Enable three authentication methods:

1. **Email** with OTP verification (under Email, Phone, Username)
2. **Google** as a social connection
3. **GitHub** as a social connection

For Google, you'll need custom credentials from Google Cloud Console (covered in the Google native sign-in section). For GitHub, development instances use shared credentials, so no extra setup is needed to get started.

### Environment variables and publishable key

Copy the [Publishable Key](https://clerk.com/docs/guides/development/clerk-environment-variables.md#clerk-publishable-and-secret-keys) from the Clerk Dashboard and create a `.env` file in your project root:

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

The `EXPO_PUBLIC_` prefix is required because Expo inlines these values at build time. Never put secret keys in `EXPO_PUBLIC_` variables since they're embedded in your app bundle and visible to anyone who decompiles it.

### Wrapping your app with ClerkProvider

Add `<ClerkProvider>` in your root layout at `app/_layout.tsx`:

```tsx
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('Add EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY to your .env file')
}

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

The `tokenCache` prop uses `expo-secure-store` under the hood. On iOS, tokens are stored in the Keychain. On Android, they're stored in SharedPreferences encrypted with the Keystore system. This means sessions persist across app restarts without the user having to sign in again. Clerk session tokens have a 60-second lifetime and are proactively refreshed in the background on a 50-second interval, so your app never blocks on token refresh.

## Building authentication with Clerk's native components

`@clerk/expo` 3.0 ships pre-built native UI components powered by SwiftUI on iOS and Jetpack Compose on Android. They render as truly native views (not web views), handle email OTP, OAuth, [passkeys](https://clerk.com/docs/guides/configure/auth-strategies/sign-up-sign-in-options.md#passkeys), and [multi-factor authentication](https://clerk.com/docs/guides/configure/auth-strategies/sign-up-sign-in-options.md#multi-factor-authentication) automatically, and sync sessions back to the JavaScript SDK.

> Expo native components are currently in beta. If you run into any issues, reach out to [Clerk support](https://clerk.com/contact/support).

> Native components (AuthView, UserButton, UserProfileView) are iOS and Android only. For cross-platform apps that include web, use the web equivalents from `@clerk/expo/web` (`<SignIn />`, `<SignUp />`, `<UserButton />`, `<UserProfile />`). A `Platform.OS` check can switch between native and web components.

### Using AuthView for sign-in and sign-up

`AuthView` handles the full authentication flow natively. Set `mode="signInOrUp"` for a single screen that handles both sign-in and sign-up. It automatically renders all auth methods you've enabled in the Dashboard, including email OTP, Google, and GitHub.

Create a sign-in screen at `app/(auth)/sign-in.tsx`:

```tsx
import { AuthView } from '@clerk/expo/native'
import { useAuth } from '@clerk/expo'
import { useRouter } from 'expo-router'
import { useEffect } from 'react'
import { View, StyleSheet } from 'react-native'

export default function SignInScreen() {
  const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false })
  const router = useRouter()

  useEffect(() => {
    if (isSignedIn) {
      router.replace('/(app)')
    }
  }, [isSignedIn])

  return (
    <View style={styles.container}>
      <AuthView mode="signInOrUp" />
    </View>
  )
}

const styles = StyleSheet.create({
  container: { flex: 1 },
})
```

Native components don't use imperative callbacks. Instead, use `useAuth()` in a `useEffect` to react to authentication state changes. When `isSignedIn` becomes true, redirect to the home screen.

> When using native components alongside `useAuth()`, pass `{ treatPendingAsSignedOut: false }` to avoid treating pending session tasks as signed-out state. This prevents flickering during session initialization.

### Adding the UserButton component

`UserButton` renders the user's circular avatar. Tapping it opens a native profile modal. It fills its parent container, so wrap it in a `View` with explicit dimensions.

Add the UserButton to your home screen at `app/(app)/index.tsx`:

```tsx
import { UserButton } from '@clerk/expo/native'
import { View, Text, StyleSheet } from 'react-native'

export default function HomeScreen() {
  return (
    <View style={styles.container}>
      <View style={styles.header}>
        <Text style={styles.title}>Home</Text>
        <View style={styles.userButton}>
          <UserButton />
        </View>
      </View>
    </View>
  )
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 24 },
  header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
  title: { fontSize: 24, fontWeight: 'bold' },
  userButton: { width: 40, height: 40 },
})
```

### UserProfileView for inline profile management

`UserProfileView` renders a full profile management screen inline: personal info, security settings, connected accounts, account switching, and sign out. Set `style={{ flex: 1 }}` so it fills the screen.

Create a profile screen at `app/(app)/profile.tsx`:

```tsx
import { UserProfileView } from '@clerk/expo/native'
import { useAuth } from '@clerk/expo'
import { useRouter } from 'expo-router'
import { useEffect } from 'react'

export default function ProfileScreen() {
  const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false })
  const router = useRouter()

  useEffect(() => {
    if (isSignedIn === false) {
      router.replace('/(auth)/sign-in')
    }
  }, [isSignedIn])

  return <UserProfileView style={{ flex: 1 }} />
}
```

Listen for sign-out via `useAuth()` and redirect when `isSignedIn` becomes false.

## Setting up OAuth: Google native sign-in

If you're using `<AuthView />`, Google Sign-In works automatically after Dashboard configuration. You don't need the `useSignInWithGoogle()` hook or `expo-crypto`. This section is for developers building custom UI who want the native OS-level account picker.

> Native Apple Sign-In follows the same pattern via `useSignInWithApple()` from `@clerk/expo`. The `@clerk/expo` config plugin automatically sets up Apple Sign-In entitlements. AuthView handles Apple Sign-In automatically when it's enabled in the Dashboard.

### Configuring Google OAuth in the Clerk Dashboard

Add Google as a social connection with custom credentials. You'll need to create OAuth 2.0 credentials in [Google Cloud Console](https://console.cloud.google.com/):

1. **iOS OAuth client ID** (Application type: iOS, with your Bundle ID)
2. **Android OAuth client ID** (Application type: Android, with your package name and SHA-1 fingerprint)
3. **Web OAuth client ID** (required for Clerk's backend token verification, even for native-only apps)

Set the Web Client ID and Client Secret in the Clerk Dashboard under Social Connections.

Then register your native app in the Clerk Dashboard under **Native Applications**:

- **iOS**: App ID Prefix (Team ID) + Bundle ID
- **Android**: namespace + package name + SHA-256 fingerprint

Add these environment variables to your `.env`:

```bash
EXPO_PUBLIC_CLERK_GOOGLE_IOS_CLIENT_ID=your-ios-client-id
EXPO_PUBLIC_CLERK_GOOGLE_ANDROID_CLIENT_ID=your-android-client-id
EXPO_PUBLIC_CLERK_GOOGLE_WEB_CLIENT_ID=your-web-client-id
EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME=com.googleusercontent.apps.your-ios-client-id
```

For the complete step-by-step, see the [Sign in with Google guide](https://clerk.com/docs/guides/configure/auth-strategies/sign-in-with-google.md).

### Native Google Sign-In with useSignInWithGoogle()

For custom UI, use the `useSignInWithGoogle()` hook. It triggers the OS-level account picker without opening a browser.

```tsx
import { useSignInWithGoogle } from '@clerk/expo/google'
import { TouchableOpacity, Text, Alert, Platform } from 'react-native'

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

  const handleGoogleSignIn = async () => {
    if (Platform.OS === 'web') {
      Alert.alert('Not supported', 'Native Google Sign-In is not available on web.')
      return
    }

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

      if (createdSessionId && setActive) {
        await setActive({ session: createdSessionId })
      }
    } catch (err: any) {
      // Error code -5 or SIGN_IN_CANCELLED means the user dismissed the picker
      if (err.code === 'SIGN_IN_CANCELLED' || err.code === '-5') {
        return
      }
      Alert.alert('Error', 'Failed to sign in with Google.')
    }
  }

  return (
    <TouchableOpacity onPress={handleGoogleSignIn}>
      <Text>Sign in with Google</Text>
    </TouchableOpacity>
  )
}
```

### Testing native Google Sign-In

Google Sign-In works on both simulators and physical devices with development builds. After any environment variable or config change, rebuild with `npx expo run:ios`. Common issues include missing client IDs, wrong bundle ID in Google Cloud Console, and forgetting to rebuild after changing config.

## Setting up OAuth: browser-based Google and GitHub

### How browser-based OAuth differs from native

Browser-based OAuth opens an in-app browser (via `expo-web-browser`), the user authenticates on the provider's website, and the app receives a redirect back via deep link. Native sign-in uses the OS-level account picker (Google's credential manager or Apple's ASAuthorizationController), which is faster since no browser opens.

The tradeoff: native feels more integrated but is only available for Google and Apple. Browser-based OAuth supports every provider Clerk offers, including GitHub, Microsoft, Discord, and more.

### Configuring redirect URIs

For browser-based OAuth, the redirect URI must match your app's scheme. Use `AuthSession.makeRedirectUri()` to generate the correct URI. It reads the scheme from `app.json` automatically.

The scheme is already set from the project setup step: `"scheme": "clerk-auth-demo"`. You also need to allowlist the redirect URL in the Clerk Dashboard for mobile [SSO](https://clerk.com/glossary/single-sign-on-sso.md) redirects.

> The `auth.expo.io` proxy (formerly used by `expo-auth-session`) is deprecated and has a [known security vulnerability (CVE-2023-28131)](https://blog.expo.dev/security-advisory-for-developers-using-authsessions-useproxy-options-and-auth-expo-io-e470fe9346df). Always use a custom scheme with `AuthSession.makeRedirectUri()`. Rebuild your development build after changing the scheme.

### Implementing browser-based OAuth with useSSO()

`useSSO()` is the Core 3 recommended hook for browser-based OAuth. It replaces the deprecated `useOAuth()`.

Create a reusable OAuth screen. Start with a browser warm-up pattern for Android performance:

```tsx
import { useEffect } from 'react'
import { Platform } from 'react-native'
import * as WebBrowser from 'expo-web-browser'

WebBrowser.maybeCompleteAuthSession()

export const useWarmUpBrowser = () => {
  useEffect(() => {
    if (Platform.OS !== 'android') return
    void WebBrowser.warmUpAsync()
    return () => {
      void WebBrowser.coolDownAsync()
    }
  }, [])
}
```

Then build the OAuth sign-in component:

```tsx
import { useSSO } from '@clerk/expo'
import * as AuthSession from 'expo-auth-session'
import { TouchableOpacity, Text, Alert, View } from 'react-native'

export function BrowserOAuthButtons() {
  useWarmUpBrowser()

  const { startSSOFlow } = useSSO()

  const handleOAuth = async (strategy: 'oauth_google' | 'oauth_github') => {
    try {
      const redirectUrl = AuthSession.makeRedirectUri()

      const { createdSessionId, setActive } = await startSSOFlow({
        strategy,
        redirectUrl,
      })

      if (createdSessionId && setActive) {
        await setActive({ session: createdSessionId })
      }
    } catch (err: any) {
      Alert.alert('Error', `OAuth sign-in failed: ${err.message}`)
    }
  }

  return (
    <View>
      <TouchableOpacity onPress={() => handleOAuth('oauth_google')}>
        <Text>Sign in with Google (Browser)</Text>
      </TouchableOpacity>

      <TouchableOpacity onPress={() => handleOAuth('oauth_github')}>
        <Text>Sign in with GitHub</Text>
      </TouchableOpacity>
    </View>
  )
}
```

The flow opens an in-app browser, the user authenticates with the provider, and the browser redirects back to your app via the custom scheme. If `createdSessionId` is returned, call `setActive()` to establish the session.

### Adding GitHub as a second provider

GitHub uses the exact same `useSSO()` pattern with `strategy: 'oauth_github'`. Configure GitHub in the Clerk Dashboard as a social connection. Development instances use shared credentials, so you don't need a GitHub OAuth app for local testing.

For production, create a [GitHub OAuth App](https://github.com/settings/developers), set the authorization callback URL from the Clerk Dashboard, and enter the client ID and secret. See the [GitHub social connection guide](https://clerk.com/docs/guides/configure/auth-strategies/social-connections/github.md) for details.

## Email and OTP authentication

### How email OTP works with Clerk

Clerk sends a [one-time passcode](https://clerk.com/docs/guides/configure/auth-strategies/sign-up-sign-in-options.md#email) to the user's email. The user enters the code. Clerk verifies it server-side. No password storage, no reset flows, no forgotten password emails.

With native components (`AuthView`), email OTP is handled automatically. AuthView renders an email input and code verification screen for any email-based auth method enabled in the Dashboard.

### Building a custom email OTP flow

For developers using the JavaScript-only approach (without AuthView), here's the custom flow using Core 3's `SignInFuture` API. Each method returns `{ error }` instead of throwing, and `signIn.status` drives the flow between steps.

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

export function EmailOTPSignIn() {
  const { signIn, fetchStatus } = useSignIn()
  const router = useRouter()
  const [emailAddress, setEmailAddress] = useState('')
  const [code, setCode] = useState('')
  const [pendingVerification, setPendingVerification] = useState(false)
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState('')

  if (fetchStatus === 'loading') return null

  const handleSendCode = async () => {
    setLoading(true)
    setError('')

    const { error: createError } = await signIn.create({ identifier: emailAddress })
    if (createError) {
      setError(createError.message || 'Failed to initiate sign-in')
      setLoading(false)
      return
    }

    const { error: sendError } = await signIn.emailCode.sendCode({ emailAddress })
    if (sendError) {
      setError(sendError.message || 'Failed to send code')
      setLoading(false)
      return
    }

    setPendingVerification(true)
    setLoading(false)
  }

  const handleVerifyCode = async () => {
    setLoading(true)
    setError('')

    const { error: verifyError } = await signIn.emailCode.verifyCode({ code })
    if (verifyError) {
      setError(verifyError.message || 'Invalid code')
      setLoading(false)
      return
    }

    if (signIn.status === 'complete') {
      await signIn.finalize({
        navigate: ({ session, decorateUrl }) => {
          if (session?.currentTask) {
            return
          }
          const url = decorateUrl('/')
          router.push(url as Href)
        },
      })
    }

    setLoading(false)
  }

  return (
    <View style={styles.container}>
      {!pendingVerification ? (
        <>
          <TextInput
            style={styles.input}
            placeholder="Email address"
            value={emailAddress}
            onChangeText={setEmailAddress}
            autoCapitalize="none"
            keyboardType="email-address"
          />
          <TouchableOpacity style={styles.button} onPress={handleSendCode} disabled={loading}>
            {loading ? (
              <ActivityIndicator color="#fff" />
            ) : (
              <Text style={styles.buttonText}>Send Code</Text>
            )}
          </TouchableOpacity>
        </>
      ) : (
        <>
          <TextInput
            style={styles.input}
            placeholder="Enter verification code"
            value={code}
            onChangeText={setCode}
            keyboardType="number-pad"
          />
          <TouchableOpacity style={styles.button} onPress={handleVerifyCode} disabled={loading}>
            {loading ? (
              <ActivityIndicator color="#fff" />
            ) : (
              <Text style={styles.buttonText}>Verify</Text>
            )}
          </TouchableOpacity>
        </>
      )}
      {error ? <Text style={styles.error}>{error}</Text> : null}
    </View>
  )
}

const styles = StyleSheet.create({
  container: { padding: 24, gap: 16 },
  input: { borderWidth: 1, borderColor: '#ccc', borderRadius: 8, padding: 12, fontSize: 16 },
  button: { backgroundColor: '#6C47FF', borderRadius: 8, padding: 14, alignItems: 'center' },
  buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
  error: { color: 'red', fontSize: 14 },
})
```

### When to use email OTP vs OAuth

OAuth is faster for returning users since it only takes one tap. Email OTP works universally because it doesn't require a third-party account, which makes it a good choice for enterprise users whose companies may restrict social logins. Most apps benefit from offering both. The native components approach with AuthView handles this automatically by rendering all enabled methods.

> If you add password-based auth later, the `useLocalCredentials()` hook from `@clerk/expo` enables biometric sign-in (Face ID/fingerprint) for returning users. It stores credentials locally via `expo-local-authentication` and `expo-secure-store`. See the [useLocalCredentials() reference](https://clerk.com/docs/reference/expo/native-hooks/use-local-credentials.md) for details.

## Protected routes with Expo Router

### Authentication state with useAuth()

The `useAuth()` hook returns `isLoaded`, `isSignedIn`, `userId`, `sessionId`, and a `getToken()` method. Always check `isLoaded` before rendering to avoid a flash of wrong content during session restoration from secure storage.

```tsx
const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false })

if (!isLoaded) {
  return <LoadingSpinner />
}
```

The `getToken()` method retrieves the current session token (a [JSON Web Token](https://clerk.com/docs/guides/how-clerk-works/tokens-and-signatures.md)) for API calls. Clerk's SDK automatically refreshes tokens in the background on a 50-second interval (tokens have a 60-second lifetime), so your app never blocks on a token refresh.

### Setting up route groups

Expo Router uses file-based routing with route groups. Create an `(auth)` group for sign-in screens and an `(app)` group for authenticated content.

```text
app/
  _layout.tsx          # Root layout with ClerkProvider + auth routing
  (auth)/
    \_layout.tsx
    sign-in.tsx        # AuthView screen
  (app)/
    _layout.tsx
    index.tsx          # Home screen with UserButton
    profile.tsx        # UserProfileView screen
```

Update your root layout at `app/_layout.tsx` to handle auth-based routing:

```tsx
import { ClerkProvider, useAuth } from '@clerk/expo'
import { tokenCache } from '@clerk/expo/token-cache'
import { useRouter, useSegments, Slot } from 'expo-router'
import { useEffect } from 'react'
import { View, ActivityIndicator } from 'react-native'

const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!

function AuthRouter() {
  const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false })
  const segments = useSegments()
  const router = useRouter()

  useEffect(() => {
    if (!isLoaded) return

    const inAuthGroup = segments[0] === '(auth)'

    if (isSignedIn && inAuthGroup) {
      router.replace('/(app)')
    } else if (!isSignedIn && !inAuthGroup) {
      router.replace('/(auth)/sign-in')
    }
  }, [isLoaded, isSignedIn, segments])

  if (!isLoaded) {
    return (
      <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
        <ActivityIndicator size="large" />
      </View>
    )
  }

  return <Slot />
}

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

The `Show` component from `@clerk/expo` can also be used for conditional rendering within screens:

```tsx
import { Show } from '@clerk/expo'
;<Stack>
  <Show when="signed-in">
    <Dashboard />
  </Show>
  <Show when="signed-out">
    <SignInPrompt />
  </Show>
</Stack>
```

Expo Router also offers `Stack.Protected` as a newer alternative to manual redirect logic:

```tsx
import { Stack } from 'expo-router'
;<Stack>
  <Stack.Protected guard={isSignedIn}>
    <Stack.Screen name="(app)" />
  </Stack.Protected>
  <Stack.Screen name="(auth)" />
</Stack>
```

When `guard` is false, navigation to protected routes fails silently and users on a now-unguarded screen are redirected to the anchor route (typically the index screen). History entries for that screen are removed. `Stack.Protected` works with `Stack`, `Tabs`, and `Drawer` navigators and has been stable since SDK 53.

> Route protection via `Stack.Protected` and `useAuth()` is client-side only. For sensitive data, always validate the session token on your server.

### Handling deep links in authenticated routes

With the route group pattern, unauthenticated users who try to deep link into a protected route get redirected to sign-in. After signing in, the `useEffect` in the root layout redirects them to the `(app)` group. If you need to redirect back to the specific deep-linked route, store the intended path in local state before redirecting to sign-in.

## Creating production builds

### Registering your native app in Clerk Dashboard

Before building for production, register your app on the Clerk Dashboard's **Native Applications** page. This step is required for native components and native sign-in hooks to work in production.

- **iOS**: Enter your App ID Prefix (Team ID) and Bundle ID
- **Android**: Enter your namespace, package name, and SHA-256 certificate fingerprint

Allowlist your redirect URL: `{bundleIdentifier}://callback`. Clerk also requires a domain for production instances, even for mobile-only apps. Configure this in the production instance settings.

For full details, see the [Expo production deployment guide](https://clerk.com/docs/guides/development/deployment/expo.md).

### Configuring eas.json for production

Create an `eas.json` file with build profiles for development, preview, and production:

```json
{
  "cli": {
    "version": ">= 15.0.0"
  },
  "build": {
    "development": {
      "developmentClient": true,
      "distribution": "internal",
      "env": {
        "EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY": "pk_test_your-dev-key"
      }
    },
    "preview": {
      "distribution": "internal",
      "env": {
        "EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY": "pk_test_your-dev-key"
      }
    },
    "production": {
      "distribution": "store",
      "env": {
        "EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY": "pk_live_your-prod-key"
      }
    }
  },
  "submit": {
    "production": {}
  }
}
```

The key difference between profiles: `development` enables `developmentClient` for dev tools, `preview` is a release build for internal testing, and `production` targets app store distribution. Each profile can have its own `EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY` to point at your development or production Clerk instance.

### Building for iOS with EAS Build

Run the production build:

```bash
eas build --platform ios --profile production
```

EAS Build compiles native code on cloud macOS runners, signs the app with auto-managed credentials (distribution certificate and provisioning profile), and outputs an `.ipa` file. You don't need to manually create certificates or provisioning profiles in the Apple Developer portal. EAS generates and manages them for you. Run `eas credentials` to inspect or reset them. The Apple Developer Program ($99/year) is required. The free EAS tier includes 15 iOS builds per month.

### Building for Android with EAS Build

```bash
eas build --platform android --profile production
```

The default output is an `.aab` (Android App Bundle) for the Play Store. For direct installation, add `"buildType": "apk"` to the production profile. EAS manages the Android keystore automatically.

> For Google OAuth on Android, the SHA-1/SHA-256 fingerprint from the EAS-managed keystore must match the Google Cloud Console configuration. Run `eas credentials` to view the fingerprint.

### Environment-specific configuration

For more flexibility, switch from `app.json` to `app.config.js` with dynamic configuration:

```typescript
const IS_DEV = process.env.APP_VARIANT === 'development'

export default {
  name: IS_DEV ? 'Clerk Auth (Dev)' : 'Clerk Auth',
  slug: 'clerk-auth-demo',
  scheme: 'clerk-auth-demo',
  ios: {
    bundleIdentifier: IS_DEV ? 'com.yourapp.dev' : 'com.yourapp',
  },
  android: {
    package: IS_DEV ? 'com.yourapp.dev' : 'com.yourapp',
  },
  plugins: ['@clerk/expo'],
}
```

This lets you install development and production builds side by side on the same device with different bundle identifiers.

## Distributing with TestFlight

### Submitting to TestFlight

The fastest way to get a build into TestFlight is a single command:

```bash
npx testflight
```

This wraps `eas build --platform ios --profile production --auto-submit`. It builds the app, uploads the `.ipa` to App Store Connect, and enables TestFlight distribution for internal testers. Internal testers (up to 100 team members) get access immediately without App Store review. [Builds expire after 90 days](https://developer.apple.com/testflight/).

You can also run the steps separately:

```bash
eas build --platform ios --profile production --auto-submit
```

Or build first and submit later:

```bash
eas build --platform ios --profile production
eas submit --platform ios
```

### Android distribution

For Android, share the `.apk` directly or use the Google Play internal testing track:

```bash
eas build --platform android --profile production
```

Add `"buildType": "apk"` to the production profile in `eas.json` for direct sharing. For the Google Play internal track, use `eas submit --platform android` (requires a Google Play Console account).

## Comparison: authentication approaches for Expo apps

All major auth providers require development builds for OAuth. Clerk's developer experience stands out: native SwiftUI/Jetpack Compose components, integrated native Google Sign-In without third-party packages, and a config plugin that handles native setup automatically.

| Feature                                |      Clerk      |       Auth0       |        Firebase Auth       |   Supabase Auth   |
| -------------------------------------- | :-------------: | :---------------: | :------------------------: | :---------------: |
| Native UI components (SwiftUI/Compose) |       Yes       |         No        |             No             |         No        |
| Works in Expo Go (basic auth)          |       Yes       |         No        |         JS SDK only        |   Email/password  |
| OAuth requires dev build               |       Yes       |        Yes        |             Yes            |        Yes        |
| Native Google Sign-In                  |       Yes       |         No        |      Separate package      | signInWithIdToken |
| Expo config plugin                     |       Yes       |        Yes        | Via @react-native-firebase |         No        |
| Free tier                              |     50K MRUs    |      25K MAUs     |          50K MAUs          |      50K MAUs     |
| Pro tier starting price                | $20/mo (annual) | Essentials $35/mo |     Usage-based (Blaze)    |       $25/mo      |

> Clerk uses Monthly Retained Users (MRUs) as its billing metric, meaning users who return 24+ hours after sign-up. Auth0, Firebase, and Supabase use Monthly Active Users (MAUs). Clerk Pro is $20/month billed annually or $25/month billed monthly. Supabase's free tier pauses databases after 7 days of inactivity.

## Frequently asked questions

## FAQ

### Do I need a development build to use Clerk with Expo?

Only for OAuth and native sign-in or native components. JavaScript-only email/password auth works in Expo Go. If your app uses social login (Google, GitHub, Apple) or Clerk's native UI components (AuthView, UserButton), you need a development build.

### Can I use Clerk with Expo Go for testing?

Yes, for JavaScript-only flows. Email/password with custom sign-in forms, `useAuth()`, and the `Show` component all work in Expo Go. OAuth redirects and native components require a development build because Expo Go can't register custom URL schemes or load custom native modules.

### How do I handle OAuth redirects in Expo?

Use `AuthSession.makeRedirectUri()` with a custom scheme set in `app.json`. The `useSSO()` hook from `@clerk/expo` handles the full browser-based OAuth flow. The deprecated `auth.expo.io` proxy has a known security vulnerability (CVE-2023-28131) and should not be used.

### What is the difference between native Google Sign-In and browser-based?

Native Google Sign-In uses the OS-level account picker (Google's credential manager) without opening a browser. Browser-based OAuth opens an in-app browser where the user authenticates on Google's website. Native is faster and feels more integrated. Browser-based supports more providers since native sign-in is only available for Google and Apple.

### How do I persist authentication state between app restarts?

Pass `tokenCache` from `@clerk/expo/token-cache` to `ClerkProvider`. It uses `expo-secure-store` for encrypted storage: iOS Keychain and Android SharedPreferences encrypted with the Keystore system. Sessions persist across app restarts automatically.

### Do I need an Apple Developer account for development builds?

No, for simulator builds via `npx expo run:ios`. You need the Apple Developer Program ($99/year) for running on physical iOS devices and distributing via TestFlight.

### How do I switch between development and production Clerk keys?

Use EAS environment variables. Set `EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY` per build profile in `eas.json` (`pk_test_` for development, `pk_live_` for production). Use `app.config.js` with `APP_VARIANT` for different bundle identifiers per environment.

### What Expo SDK version does @clerk/expo 3.0 require?

Expo SDK 53 or higher. The `@clerk/expo` 3.x peer dependency supports SDK 53 through 55. The current stable SDK is 55, which uses React Native 0.83.

### Can I use Clerk's native components on both iOS and Android?

Yes. AuthView, UserButton, and UserProfileView render SwiftUI on iOS and Jetpack Compose on Android. They're currently in beta. For web, use the web equivalents from `@clerk/expo/web`.

### How do I debug OAuth redirect issues in development builds?

Check four things: (1) the scheme in `app.json` matches your redirect URI, (2) the redirect URL is allowlisted in the Clerk Dashboard, (3) you rebuilt after any config changes (`npx expo run:ios`), and (4) Google Cloud Console client IDs match your bundle ID or package name.
