Skip to main content
Articles

Expo Go or Development Build? Building Production-Ready Authentication with Clerk

Author: Roy Anger
Published: (last updated )

Welcome to Part 1 of our guide on building production-ready authentication in Expo. In this part, we explore the differences between Expo Go and development builds, set up a new project with Clerk, and implement native Google Sign-In using Clerk's pre-built native UI components. In Part 2, we will cover browser-based OAuth, custom email OTP flows, route protection, and production deployment.

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 and the clerk-expo-quickstart repository.

Note

This tutorial builds a new app with @clerk/expo 3.0 (Core 3). If you're upgrading an existing project from @clerk/clerk-expo (Core 2), see the Core 3 upgrade guide 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 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:

ApproachWorks in Expo Go?Dev build required?What you get
JavaScript-onlyCustom sign-in/sign-up UI with email/password. Full control, most code.
JS + native sign-inCustom UI + native Google/Apple sign-in via OS-level account picker. Less code than full custom.
Native componentsPre-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 ($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 (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:

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

Installing Clerk and dependencies

Install the required packages:

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:

{
  "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:

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. 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 from the Clerk Dashboard and create a .env file in your project root:

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:

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, and multi-factor authentication automatically, and sync sessions back to the JavaScript SDK.

Warning

Expo native components are currently in beta. If you run into any issues, reach out to Clerk support.

Note

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:

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.

Important

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:

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:

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.

Tip

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:

  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:

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.

Native Google Sign-In with useSignInWithGoogle()

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

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.

Wrapping up Part 1

You now have a solid foundation for your Expo application, complete with Clerk's native UI components and native Google Sign-In running in a development build. In Part 2, we will expand on this by implementing browser-based OAuth for providers like GitHub, building a custom email OTP flow, protecting routes with Expo Router, and finally preparing your app for production distribution via TestFlight.

Frequently asked questions

Can I use native Google Sign-In in Expo Go?

No. Native Google Sign-In requires custom native modules and URL schemes that Expo Go does not support. You must use a development build to implement native Google Sign-In.

Do I need a paid Apple Developer account to test development builds?

No. You can test iOS development builds on a local simulator without a paid Apple Developer account. A paid account is only required when you want to test on physical iOS devices or distribute your app via TestFlight or the App Store.

In this series

  1. Expo Go or Development Build? Building Production-Ready Authentication with Clerk (you are here)
  2. Expo Go or Development Build? Building Production-Ready Authentication with Clerk - Part 2