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

> Part 2 of 2. Start with [Expo Go or Development Build? Building Production-Ready Authentication with Clerk](https://clerk.com/articles/expo-go-or-development-build-building-production-ready-authentication-with-clerk.md).

Welcome to Part 2 of our guide on building production-ready authentication in Expo. In Part 1, we covered the differences between Expo Go and development builds, project setup, and native Google Sign-In using Clerk's native UI components. In this part, we will implement browser-based OAuth, build a custom email OTP flow, protect our routes using Expo Router, and prepare the app for production distribution via TestFlight.

## 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 === 'fetching') 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'
import { Stack } from 'expo-router'
;<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.

## Conclusion

You have successfully built a production-ready Expo application with comprehensive authentication. By moving from Expo Go to a development build, you unlocked native capabilities like Google Sign-In and custom URL schemes for browser-based OAuth. With your routes protected and EAS Build configured, your app is now ready for internal testing via TestFlight and eventual App Store distribution.

## Frequently asked questions

### Why should I use browser-based OAuth instead of native sign-in?

While native sign-in provides a faster, more integrated experience by using the OS-level account picker, it is only available for Google and Apple. Browser-based OAuth supports a much wider range of providers, such as GitHub, Microsoft, and Discord.

### How does Clerk handle session tokens in Expo?

Clerk stores session data securely on-device using the `tokenCache` from `@clerk/expo/token-cache`, which is backed by Expo SecureStore. The session token itself is a short-lived JWT—it has a 60-second lifetime, and Clerk's SDK automatically refreshes it in the background on a 50-second interval, so your app never blocks on a refresh. Call `getToken()` from the `useAuth()` hook whenever you need the current token to authenticate requests to your backend.

## In this series

1. [Expo Go or Development Build? Building Production-Ready Authentication with Clerk](https://clerk.com/articles/expo-go-or-development-build-building-production-ready-authentication-with-clerk.md)
2. **Expo Go or Development Build? Building Production-Ready Authentication with Clerk - Part 2** (you are here)
