Skip to main content
Articles

Expo Google Sign-In Without a WebView: The Native Approach Using Clerk

Author: Roy Anger
Published:

Google Sign-In in Expo apps has always meant browser redirects, custom URL schemes, and a fragile chain of callbacks. Clerk's native Google Sign-In changes that. On Android, it uses Credential Manager — no browser at all. On iOS, configuring the EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME environment variable enables ASAuthorization, Apple's native credential picker, instead of the default system browser sheet. With both platforms configured, the user taps one button, picks their Google account from a system-level sheet, and they're signed in.

This guide walks through the complete setup: Google Cloud credentials, Clerk Dashboard configuration, and a working Expo app with native Google Sign-In, email+OTP authentication, user profile management, and sign-out. Every code example targets @clerk/expo Core 3 and the current stable Expo SDK.

What Is Native Google Sign-In and Why It Matters for Expo Apps

Browser-Based OAuth: The Standard Approach and Its Problems in Expo

The standard OAuth flow in Expo uses expo-auth-session to open a system browser (ASWebAuthenticationSession on iOS, Chrome Custom Tabs on Android). The user authenticates in that browser and gets redirected back to the app via a deep link.

This works, but the failure modes are real:

  • Redirect handling breaks. Different callback URIs for development, preview, and production. One mismatch and the user lands nowhere.
  • Android dismiss race conditions. Developers have reported Android redirect reliability issues where the browser dismisses before the callback completes (expo/expo#23781).
  • SDK upgrades break auth. Expo SDK 53 introduced regressions in Google login flows that affected existing expo-auth-session implementations (expo/expo#38666).
  • The auth.expo.io proxy is gone. The Google provider that relied on it has been deprecated since SDK 49 (expo/expo#21084).

Google blocked OAuth from embedded WebViews on September 30, 2021, returning disallowed_useragent errors (Google Developers Blog, Jun 2021). Google continued enforcing this policy through 2023: remaining apps using embedded WebViews saw warnings starting in February 2023, with final blocking on July 24, 2023 (Google Support FAQ). The system browser approach (expo-auth-session) was never blocked, but it still opens a browser. Native sign-in avoids a browser entirely.

Three tiers of Google authentication exist in mobile apps:

  1. Embedded WebView (blocked by Google since 2021)
  2. System browser via ASWebAuthenticationSession/Chrome Custom Tabs (what expo-auth-session does)
  3. Native credential picker via ASAuthorization/Credential Manager (what Clerk's native flow does)

This article covers tier 3: no browser at all.

What Native Google Sign-In Actually Is

ASAuthorization on iOS

Apple's ASAuthorization framework presents a system-level credential picker, the same UI used for passkeys and Sign in with Apple. When Clerk's @clerk/expo config plugin is configured with an iOS URL scheme (EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME), useSignInWithGoogle() uses ASAuthorization to present the native Google account picker. No browser opens.

This configuration step is optional. Without it, iOS falls back to ASWebAuthenticationSession, which opens a system browser sheet. The difference is a single environment variable.

Credential Manager on Android

Credential Manager is Google's Jetpack library (androidx.credentials) that surfaces a system bottom sheet with the user's Google accounts. No browser opens. An ID token is produced directly by the OS.

Credential Manager replaces 5 deprecated APIs: the legacy Google Sign-In SDK (play-services-auth), Smart Lock for Passwords, One Tap sign-in, the Sign in with Google button, and FIDO2 local credentials. SDK removal is scheduled for May 2026; API calls will fail as early as July 2028 (Android Developers Blog, Sep 2024).

Note

Platform behavior summary:

  • Android: Credential Manager. No browser at all.
  • iOS with native config: ASAuthorization. System credential picker, no browser.
  • iOS without native config: ASWebAuthenticationSession. System browser sheet (fallback).

Configure EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME in your app.config.ts to get the native credential picker on iOS.

Why Native Sign-In Is Better Than Browser-Based OAuth

User experience. No context switch. No redirect failures. No browser tab left open. The conversion numbers back this up:

  • Pinterest saw a 126% sign-up increase on Android after adopting Google One Tap (Google Case Study).
  • Reddit reported a 185% overall conversion increase combining Sign in with Google and One Tap (Google Case Study).
  • Zoho achieved 6x faster logins after migrating to Credential Manager, with 31% month-over-month passkey adoption growth (Android Developers Blog, May 2025).

Security. The native flow runs in a sandboxed system process that the app can't intercept. No redirect URI to spoof. No PKCE complexity exposed to the developer. RFC 8252 (IETF BCP 212) states that native apps "MUST NOT use embedded user-agents" for OAuth. The OAuth 2.1 draft (March 2026) makes PKCE mandatory for all clients.

Reliability. No auth.expo.io proxy dependency. No dismiss race conditions on Android. No production-vs-development differences in redirect handling.

Prerequisites and Requirements

Warning

Native Google Sign-In requires a development build. It won't work in Expo Go. Use npx expo run:ios, npx expo run:android, or eas build --profile development instead.

Tools and Accounts You Need

Environment Variable Checklist

Your .env file needs these values (collected during the setup steps below):

EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
EXPO_PUBLIC_CLERK_GOOGLE_WEB_CLIENT_ID=...
EXPO_PUBLIC_CLERK_GOOGLE_IOS_CLIENT_ID=...
EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME=com.googleusercontent.apps...
EXPO_PUBLIC_CLERK_GOOGLE_ANDROID_CLIENT_ID=...

Compatibility: Clerk Core 3, @clerk/expo, and Expo SDK

  • All code uses Core 3 import paths: @clerk/expo, @clerk/expo/google, @clerk/expo/native
  • Native Google Sign-In requires @clerk/expo 3.1+
  • Native components (AuthView, UserButton, UserProfileView) require Expo SDK 53+
  • React 18 or 19

Important

Native components (AuthView, UserButton, UserProfileView) are currently in beta. See the Native Components Overview for the latest status.

Development Build vs. Expo Go

Native Google Sign-In requires a development build because Expo Go ships a fixed native layer that can't load custom TurboModules like NativeClerkGoogleSignIn.

FeatureExpo GoDev Build
Email/password sign-in
Google Sign-In (native)
<AuthView />
<UserButton />
Apple Sign-In (native)

To create a development build:

npx expo install expo-dev-client
npx expo run:ios

Or use EAS Build:

eas build --profile development --platform ios

How to Add Google Sign-In to an Expo App Without a Browser Redirect

Three main approaches exist. Here's how they compare:

ApproachBrowser RequiredNative Google UXSession ManagementOperational Burden
expo-auth-sessionManualRedirect URIs, browser callbacks, token exchange
@react-native-google-signinManualNative Google setup plus separate session handling
ClerkDashboard setup plus native app registration

Note

On iOS, Clerk's native path requires the URL scheme config. Without it, iOS falls back to a browser sheet. The "No" for Browser Required applies when native config is complete.

Option 1: Manual OAuth with expo-auth-session

The browser-based approach. Opens a system browser, handles the OAuth redirect, and returns a token. You manage session creation, token storage, and refresh yourself. Every Expo SDK upgrade risks breaking the redirect chain.

Option 2: @react-native-google-signin/google-signin (DIY)

A React Native library that wraps Google's native SDKs. Gives you the native Google UI, but you still own session management, user state, and sign-out logic. The Credential Manager integration is gated behind a paid tier ($89–249/year).

Native Google Sign-In is built into @clerk/expo. On Android it uses Credential Manager. On iOS, the native path uses ASAuthorization when configured. Clerk handles the token exchange, session creation, and signed-in state after the provider returns.

Why Clerk Is the Right Choice for Expo Authentication

  • Fewest moving parts. Dashboard config, environment variables, one hook or component. That's it.
  • Pre-built native UI. <AuthView /> renders SwiftUI on iOS and Jetpack Compose on Android. Google, Apple, email, phone, passkeys, and MFA are handled automatically.
  • Uses Google's current recommended APIs. Credential Manager (not the deprecated legacy SDK).
  • Built-in session management. User profiles, sign-out, and token refresh come included.
  • Automatic transfer flow. If someone signs in with Google but doesn't have an account, one is created. If they sign up but already have an account, they're signed in. No separate screens needed.
  • Minimal dependency surface. @clerk/expo with its peer dependencies (expo-secure-store, expo-auth-session, expo-web-browser) plus expo-crypto for the hook approach. AuthView doesn't need expo-crypto.

Clerk's native flow still goes through Clerk's backend for token verification and session creation. For how this works under the hood, see How Clerk Works.

Setting Up Clerk for Native Google Sign-In

Step 1: Create a Clerk Application and Enable Native API

  1. Go to the Clerk Dashboard and create a new application (or select an existing one).
  2. Navigate to the Native Applications page and confirm that Native API is enabled.
  3. Copy your Publishable Key from the API Keys page.

Native components and native sign-in hooks depend on Native API being enabled. Skip this and every native call silently fails.

Step 2: Enable Google and Register Native Applications

  1. In the Clerk Dashboard, go to Social Connections > Google > Use custom credentials.
  2. You'll configure the Client IDs here after creating them in Google Cloud Console (next step).
  3. On the Native Applications page:
    • iOS: Add your Team ID and Bundle ID (must match ios.bundleIdentifier in app.config.ts)
    • Android: Add your package name (must match android.package in app.config.ts) and SHA-256 certificate fingerprint

Note

Different builds use different signing identities. A development build, an EAS-managed build, and a Google Play App Signing key each have different certificate fingerprints. Register the SHA-256 for each in the Clerk Dashboard.

Step 3: Create a Google Cloud Project and OAuth Credentials

Before creating client IDs, Google Cloud asks you to configure an OAuth consent screen.

Common blockers at this step:

  • The app is still in testing mode (only test users can authenticate)
  • Your Google account isn't listed as a test user
  • You haven't completed production publishing for broader access

Set the consent screen to "External" and add your own email as a test user. You can publish to production later.

iOS Client ID

  1. Go to Google Cloud Console > APIs & Services > Credentials > Create OAuth Client ID
  2. Application type: iOS
  3. Bundle ID: must match your app.config.ts ios.bundleIdentifier exactly
  4. Save and note:
    • The iOS Client ID (goes into EXPO_PUBLIC_CLERK_GOOGLE_IOS_CLIENT_ID)
    • The reversed client ID (goes into EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME for the native callback path)

Android Client ID and SHA-1 Fingerprint

  1. Application type: Android
  2. Package name: must match your app.config.ts android.package
  3. SHA-1 fingerprint: get it from your signing keystore

Three different SHA-1 values exist depending on how you build:

# Debug keystore (local development)
keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android
# EAS managed keystore
eas credentials --platform android

For production, find the App Signing key SHA-1 in Google Play Console > Release > Setup > App Integrity.

  1. Create a Web Application OAuth Client ID too. Clerk uses it server-side for token verification. Add the Authorized Redirect URI from the Clerk Dashboard to this web client.

Important

Google Cloud Console requires SHA-1 for the Android OAuth Client ID. The Clerk Dashboard Native Applications page requires SHA-256. One keytool -list -v command outputs both values — copy each to the correct place.

Configuration Validation Checklist

Before your first build, confirm:

  1. Bundle ID matches in Google Cloud Console, Clerk Native Applications, and app.config.ts
  2. Android package name matches in Google Cloud Console, Clerk Native Applications, and app.config.ts
  3. Web, iOS, and Android Client IDs are in the correct environment variables
  4. SHA-1 is registered in Google Cloud Console for each Android signing identity
  5. SHA-256 is registered in the Clerk Dashboard Native Applications page for each Android signing identity
  6. iOS reversed client ID is used as EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME
  7. Native API is enabled on the Clerk Dashboard Native Applications page

Building the Complete Expo App

Project Initialization

npx create-expo-app expo-clerk-google-signin
cd expo-clerk-google-signin

Installing Dependencies

For the hook approach (custom UI, used in the complete app below):

npx expo install @clerk/expo expo-secure-store expo-crypto expo-auth-session expo-web-browser expo-dev-client

For the AuthView approach (pre-built native UI):

npx expo install @clerk/expo expo-secure-store expo-auth-session expo-web-browser expo-dev-client

AuthView doesn't need expo-crypto or useSignInWithGoogle. It handles everything internally. The expo-auth-session and expo-web-browser packages are peer dependencies of @clerk/expo and are required even when using native components.

Configuring app.config.ts

import { ExpoConfig } from 'expo/config'

const config: ExpoConfig = {
  name: 'expo-clerk-google-signin',
  slug: 'expo-clerk-google-signin',
  version: '1.0.0',
  scheme: 'expo-clerk-google-signin',
  ios: {
    bundleIdentifier: 'com.yourcompany.expoclerkgooglesignin',
    supportsTablet: true,
  },
  android: {
    package: 'com.yourcompany.expoclerkgooglesignin',
    adaptiveIcon: {
      foregroundImage: './assets/adaptive-icon.png',
      backgroundColor: '#ffffff',
    },
  },
  plugins: ['@clerk/expo'],
  extra: {
    EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME: process.env.EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME,
  },
}

export default config

The @clerk/expo config plugin auto-injects the clerk-ios (Swift) and clerk-android (Kotlin) native SDKs, sets the iOS deployment target to 17.0, and configures the iOS URL scheme for the native Google callback.

Setting Up ClerkProvider in Your App Entry Point

// 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 uses expo-secure-store under the hood, which encrypts session tokens before storing them on the device. Without it, Clerk stores the active session token in memory only and it won't persist across app restarts.

The publishableKey must be passed explicitly because environment variables aren't automatically inlined in React Native production builds the way they are on web.

Using the Native AuthView Component for Google Sign-In

<AuthView /> is the fastest way to add Google Sign-In. It renders SwiftUI on iOS and Jetpack Compose on Android, with every auth method enabled in your Clerk Dashboard available automatically. Zero auth code required.

Warning

<AuthView /> requires a development build. It won't render in Expo Go.

When to Use AuthView vs. the Hook

AuthViewuseSignInWithGoogle Hook
Lines of code~10~50
Custom UINo (SwiftUI/Compose rendered)Full control
Extra dependencies beyond @clerk/expoNoneexpo-crypto
Google + Apple + emailAutomaticBuild each manually
MFA supportAutomaticManual
Beta statusYesNot labeled beta
Web supportNo (use @clerk/expo/web)No (use @clerk/expo/web)

Basic AuthView Setup

// 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()
  const router = useRouter()

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

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

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

AuthView fills its parent container. Style the parent View to control size and position.

Customizing the AuthView Appearance

Three modes are available:

  • signIn: sign-in flows only
  • signUp: sign-up flows only
  • signInOrUp: auto-determines based on whether an account exists (default)

The isDismissable prop adds a dismiss button (defaults to false). Don't use isDismissable with React Native <Modal> as they conflict.

Which social login providers appear is controlled entirely by your Clerk Dashboard configuration. Enable Google, Apple, or any other provider there, and AuthView picks it up automatically.

Implementing Google Sign-In with the useSignInWithGoogle Hook

For full control over the UI, use the useSignInWithGoogle hook. This is the approach the complete app example uses.

The Sign-In Screen Component

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

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

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

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

      if (createdSessionId && setActive) {
        await setActive({ session: createdSessionId })
      }
    } catch (err: any) {
      // User cancelled: don't show an error toast
      if (err?.code === 'SIGN_IN_CANCELLED' || err?.code === '-5') return

      Alert.alert('Sign-in error', err?.message ?? 'Something went wrong')
    }
  }

  return (
    <TouchableOpacity style={styles.button} onPress={handlePress}>
      <Text style={styles.text}>Continue with Google</Text>
    </TouchableOpacity>
  )
}

const styles = StyleSheet.create({
  button: {
    backgroundColor: '#4285F4',
    paddingVertical: 14,
    borderRadius: 8,
    alignItems: 'center',
  },
  text: { color: '#fff', fontSize: 16, fontWeight: '600' },
})

Import useSignInWithGoogle from @clerk/expo/google (not from @clerk/expo directly).

Handling Sign-In Success and Errors

startGoogleAuthenticationFlow() returns:

  • createdSessionId: the session ID if authentication succeeded
  • setActive: function to activate the session
  • signIn / signUp: the underlying Clerk objects (rarely needed)

On success, call setActive({ session: createdSessionId }). Clerk's token cache persists the session so the user stays signed in across app restarts.

Transfer flow: if someone signs in with Google but doesn't have a Clerk account, one is created automatically. If they sign up but already have an account, Clerk signs them in. No separate sign-in/sign-up screens needed for the Google flow.

Account linking: if the user's Google email matches an existing Clerk account, accounts are linked automatically when both emails are verified.

Triggering the Native Google Sign-In Flow

When the user taps the button:

  • Android: A bottom sheet appears from Credential Manager showing the user's Google accounts. They tap one, and the flow completes. No browser opens.
  • iOS (with native config): The ASAuthorization system credential picker appears. Same pattern: tap, done, no browser.
  • iOS (without native config): Falls back to ASWebAuthenticationSession, which opens a system browser sheet.

The flow is managed entirely by the OS. Your app receives a session ID on success.

Adding Email + OTP Authentication Alongside Google Sign-In

The complete app combines Google Sign-In with email one-time passcode authentication on the same screen, with a visual separator between them.

Building the Combined Sign-Up Screen

// app/(auth)/sign-up.tsx
import { useState } from 'react'
import { View, Text, TextInput, TouchableOpacity, Alert, StyleSheet } from 'react-native'
import { useSignUp } from '@clerk/expo'
import { Link } from 'expo-router'
import { GoogleSignInButton } from '../../components/GoogleSignInButton'

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

  const handleEmailSignUp = async () => {
    try {
      await signUp.create({ emailAddress: email })
      await signUp.verifications.sendEmailCode()
      setPendingVerification(true)
    } catch (err: any) {
      Alert.alert('Error', err?.message ?? 'Could not create account')
    }
  }

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

      if (signUp.status === 'complete') {
        await signUp.finalize()
      }
    } catch (err: any) {
      Alert.alert('Verification failed', err?.message ?? 'Invalid code')
    }
  }

  if (pendingVerification) {
    return (
      <View style={styles.container}>
        <Text style={styles.title}>Verify your email</Text>
        <Text style={styles.subtitle}>We sent a code to {email}</Text>
        <TextInput
          value={code}
          onChangeText={setCode}
          placeholder="Enter 6-digit code"
          keyboardType="number-pad"
          maxLength={6}
          style={styles.input}
        />
        <TouchableOpacity style={styles.primaryButton} onPress={handleVerify}>
          <Text style={styles.primaryButtonText}>Verify</Text>
        </TouchableOpacity>
      </View>
    )
  }

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Create an account</Text>

      <GoogleSignInButton />

      <View style={styles.divider}>
        <View style={styles.dividerLine} />
        <Text style={styles.dividerText}>or</Text>
        <View style={styles.dividerLine} />
      </View>

      <TextInput
        value={email}
        onChangeText={setEmail}
        placeholder="Email address"
        autoCapitalize="none"
        keyboardType="email-address"
        style={styles.input}
      />

      <TouchableOpacity style={styles.primaryButton} onPress={handleEmailSignUp}>
        <Text style={styles.primaryButtonText}>Send code</Text>
      </TouchableOpacity>

      <Link href="/(auth)/sign-in" asChild>
        <TouchableOpacity style={styles.linkButton}>
          <Text style={styles.linkText}>Already have an account? Sign in</Text>
        </TouchableOpacity>
      </Link>
    </View>
  )
}

const styles = StyleSheet.create({
  container: { flex: 1, justifyContent: 'center', padding: 24 },
  title: { fontSize: 28, fontWeight: 'bold', marginBottom: 24 },
  subtitle: { fontSize: 14, color: '#666', marginBottom: 16 },
  input: {
    borderWidth: 1,
    borderColor: '#ddd',
    padding: 14,
    borderRadius: 8,
    fontSize: 16,
    marginBottom: 16,
  },
  primaryButton: {
    backgroundColor: '#000',
    paddingVertical: 14,
    borderRadius: 8,
    alignItems: 'center',
    marginBottom: 12,
  },
  primaryButtonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
  divider: {
    flexDirection: 'row',
    alignItems: 'center',
    marginVertical: 20,
  },
  dividerLine: { flex: 1, height: 1, backgroundColor: '#ddd' },
  dividerText: { marginHorizontal: 12, color: '#999', fontSize: 14 },
  linkButton: { alignItems: 'center', marginTop: 8 },
  linkText: { color: '#666', fontSize: 14 },
})
// app/(auth)/sign-in.tsx
import { useState } from 'react'
import { View, Text, TextInput, TouchableOpacity, Alert, StyleSheet } from 'react-native'
import { useSignIn } from '@clerk/expo'
import { Link } from 'expo-router'
import { GoogleSignInButton } from '../../components/GoogleSignInButton'

export default function SignInScreen() {
  const { signIn } = useSignIn()
  const [email, setEmail] = useState('')
  const [pendingVerification, setPendingVerification] = useState(false)
  const [code, setCode] = useState('')

  const handleEmailSignIn = async () => {
    try {
      await signIn.emailCode.sendCode({ emailAddress: email })
      setPendingVerification(true)
    } catch (err: any) {
      Alert.alert('Error', err?.message ?? 'Could not send code')
    }
  }

  const handleVerify = async () => {
    try {
      await signIn.emailCode.verifyCode({ code })

      if (signIn.status === 'complete') {
        await signIn.finalize()
      } else if (signIn.status === 'needs_second_factor') {
        // Handle MFA if enabled. See:
        // /docs/guides/development/custom-flows/authentication/email-sms-otp
        Alert.alert('MFA required', 'Complete second factor authentication')
      }
    } catch (err: any) {
      Alert.alert('Verification failed', err?.message ?? 'Invalid code')
    }
  }

  if (pendingVerification) {
    return (
      <View style={styles.container}>
        <Text style={styles.title}>Check your email</Text>
        <Text style={styles.subtitle}>We sent a code to {email}</Text>
        <TextInput
          value={code}
          onChangeText={setCode}
          placeholder="Enter 6-digit code"
          keyboardType="number-pad"
          maxLength={6}
          style={styles.input}
        />
        <TouchableOpacity style={styles.primaryButton} onPress={handleVerify}>
          <Text style={styles.primaryButtonText}>Verify</Text>
        </TouchableOpacity>
      </View>
    )
  }

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Sign in</Text>

      <GoogleSignInButton />

      <View style={styles.divider}>
        <View style={styles.dividerLine} />
        <Text style={styles.dividerText}>or</Text>
        <View style={styles.dividerLine} />
      </View>

      <TextInput
        value={email}
        onChangeText={setEmail}
        placeholder="Email address"
        autoCapitalize="none"
        keyboardType="email-address"
        style={styles.input}
      />

      <TouchableOpacity style={styles.primaryButton} onPress={handleEmailSignIn}>
        <Text style={styles.primaryButtonText}>Send code</Text>
      </TouchableOpacity>

      <Link href="/(auth)/sign-up" asChild>
        <TouchableOpacity style={styles.linkButton}>
          <Text style={styles.linkText}>Don't have an account? Sign up</Text>
        </TouchableOpacity>
      </Link>
    </View>
  )
}

const styles = StyleSheet.create({
  container: { flex: 1, justifyContent: 'center', padding: 24 },
  title: { fontSize: 28, fontWeight: 'bold', marginBottom: 24 },
  subtitle: { fontSize: 14, color: '#666', marginBottom: 16 },
  input: {
    borderWidth: 1,
    borderColor: '#ddd',
    padding: 14,
    borderRadius: 8,
    fontSize: 16,
    marginBottom: 16,
  },
  primaryButton: {
    backgroundColor: '#000',
    paddingVertical: 14,
    borderRadius: 8,
    alignItems: 'center',
    marginBottom: 12,
  },
  primaryButtonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
  divider: {
    flexDirection: 'row',
    alignItems: 'center',
    marginVertical: 20,
  },
  dividerLine: { flex: 1, height: 1, backgroundColor: '#ddd' },
  dividerText: { marginHorizontal: 12, color: '#999', fontSize: 14 },
  linkButton: { alignItems: 'center', marginTop: 8 },
  linkText: { color: '#666', fontSize: 14 },
})

Tip

The Google Sign-In button handles both sign-in and sign-up via Clerk's transfer flow. A user tapping "Continue with Google" on either screen gets the right outcome automatically.

Verifying the OTP Code

Both screens use inline verification. After signIn.emailCode.verifyCode() or signUp.verifications.verifyEmailCode() succeeds, call finalize() to activate the session. The <Show> components in the layouts detect the auth state change and redirect automatically.

For production, handle non-happy-path status values like needs_second_factor (MFA enabled) and needs_client_trust. See the Email/SMS OTP Custom Flow docs for the complete set of status codes.

Managing Sessions, the User Profile, and Sign-Out

Checking Authentication State

// app/(auth)/_layout.tsx
import { Show } from '@clerk/expo'
import { Redirect, Slot } from 'expo-router'

export default function AuthLayout() {
  return (
    <Show when="signed-out" fallback={<Redirect href="/(home)" />}>
      <Slot />
    </Show>
  )
}
// app/(home)/_layout.tsx
import { Show } from '@clerk/expo'
import { Redirect, Slot } from 'expo-router'

export default function HomeLayout() {
  return (
    <Show when="signed-in" fallback={<Redirect href="/(auth)/sign-in" />}>
      <Slot />
    </Show>
  )
}

The <Show> component from @clerk/expo replaces the older <SignedIn> / <SignedOut> components. Use when="signed-in" or when="signed-out" to conditionally render based on auth state.

The Native UserButton and UserProfile Components

// app/(home)/index.tsx
import { View, Text, StyleSheet } from 'react-native'
import { Show } from '@clerk/expo'
import { UserButton } from '@clerk/expo/native'
import { useUser } from '@clerk/expo'

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

  return (
    <Show when="signed-in">
      <View style={styles.container}>
        <View style={styles.header}>
          <Text style={styles.greeting}>Welcome, {user?.firstName ?? 'there'}</Text>
          <View style={styles.avatar}>
            <UserButton />
          </View>
        </View>
        <Text style={styles.email}>{user?.primaryEmailAddress?.emailAddress}</Text>
      </View>
    </Show>
  )
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 24, paddingTop: 80 },
  header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
  greeting: { fontSize: 24, fontWeight: 'bold' },
  avatar: { width: 44, height: 44, borderRadius: 22, overflow: 'hidden' },
  email: { fontSize: 14, color: '#666', marginTop: 8 },
})

useAuth() returns isSignedIn, userId, sessionId, and getToken. useUser() returns the full user object with user.firstName, user.primaryEmailAddress, user.imageUrl, and more.

<UserButton /> from @clerk/expo/native renders the user's avatar. Tapping it opens a native profile modal powered by <UserProfileView />. Sign-out is handled automatically and synced with the JS SDK. The component takes no props; control size and shape through the parent View.

For more control, use the useUserProfileModal() hook:

import { useUserProfileModal } from '@clerk/expo'

const { presentUserProfile, isAvailable } = useUserProfileModal()

// Open the profile modal programmatically
if (isAvailable) {
  await presentUserProfile()
}
import { useClerk } from '@clerk/expo'
import { useRouter } from 'expo-router'

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

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

  return (
    <TouchableOpacity onPress={handleSignOut}>
      <Text>Sign out</Text>
    </TouchableOpacity>
  )
}

signOut() clears the session and the token cache. If you're using <UserButton />, sign-out is built in and syncs automatically with the JS SDK.

Error Handling Reference

Android Error Code 10: SHA-1 Fingerprint Mismatch

The most common error. Surfaces as DEVELOPER_ERROR with code 10. The Google sign-in dialog appears but immediately fails.

Root cause: SHA-1 registered in Google Cloud Console doesn't match the keystore that signed the current build.

Three different SHA-1 values to manage:

  1. Debug keystore (local npx expo run:android):
keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android
  1. EAS managed keystore (cloud builds):
eas credentials --platform android
  1. Google Play App Signing key (production): found in Play Console \u003e Release \u003e Setup \u003e App Integrity.

Each needs its own Android OAuth Client ID in Google Cloud Console.

Also check: the webClientId environment variable must reference the Web Application type Client ID, not the Android one.

iOS: "The operation could not be completed"

Usually a configuration mismatch:

  • EXPO_PUBLIC_CLERK_GOOGLE_IOS_CLIENT_ID doesn't match the Google Cloud Console iOS Client ID
  • ios.bundleIdentifier in app.config.ts doesn't match what's registered in Google Cloud Console
  • EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME isn't set (or isn't the reversed client ID format, e.g., com.googleusercontent.apps.123456)

Expo Go Limitations with Native Sign-In

useSignInWithGoogle() and <AuthView /> won't work in Expo Go. The TurboModule NativeClerkGoogleSignIn isn't available.

Use a development build (npx expo run:ios) or EAS Build (eas build --profile development). JS-only email flows via useSignIn/useSignUp work in Expo Go for testing other parts of the app.

For Clerk's native Google flow, the main iOS pitfall is the Google callback URL scheme and native app identifiers, not a custom Expo redirect URI.

Common mistakes:

  • Bundle ID or package name in app.config.ts doesn't match Google Cloud Console and Clerk Dashboard entries
  • The iOS URL scheme doesn't match the reversed client ID
  • Forgetting to register Native Applications in the Clerk Dashboard (Team ID + Bundle ID for iOS, package name + SHA-256 for Android)

Platform-Specific Configuration

iOS: Info.plist and URL Schemes

The @clerk/expo config plugin handles iOS configuration automatically:

  • Injects the iOS URL scheme from EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME
  • Sets the deployment target to iOS 17.0
  • Adds the clerk-ios SPM package
  • Includes the Apple Privacy Manifest (required since May 1, 2024)

No manual Info.plist editing required.

Android: Credential Manager

Key differences from Firebase/Supabase approaches:

  • No google-services.json required. Clerk doesn't use Firebase for authentication.
  • SHA-1 is required in Google Cloud Console for the Android OAuth Client ID. SHA-256 is required in the Clerk Dashboard's Native Applications page. Both come from keytool -list -v.
  • Credential Manager requires Google Play Services. Your emulator must include the Google Play Store image.
  • Supports Android 4.4+ for passwords, Android 9+ for passkeys.

EAS Build Configuration for Native Google Sign-In

{
  "cli": {
    "version": ">= 14.0.0"
  },
  "build": {
    "development": {
      "developmentClient": true,
      "distribution": "internal",
      "env": {
        "EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY": "pk_test_..."
      }
    },
    "preview": {
      "distribution": "internal"
    },
    "production": {
      "autoIncrement": true
    }
  }
}
eas build --profile development --platform ios

Set developmentClient: true and distribution: "internal". Environment variables can be set per profile or in the EAS Dashboard.

Preview and Production Builds

For production, the most common Google Sign-In failure is SHA-1 mismatch:

  • Google Play App Signing uses an app signing key that's different from the upload key
  • Both need their own Android OAuth Client IDs in Google Cloud Console

Important

Production Expo apps still need a domain on the Clerk production instance, even if there's no traditional web frontend. See the Expo Deployment Guide for details.

Migrating from Browser-Based Google OAuth

From expo-auth-session

Remove: useAuthRequest, Google provider imports, redirect URI config, makeRedirectUri, promptAsync.

Keep: expo-auth-session and expo-web-browser (peer dependencies of @clerk/expo).

Add: @clerk/expo, expo-secure-store, expo-dev-client, and expo-crypto (hook approach only).

Replace: useAuthRequest and the entire OAuth flow with useSignInWithGoogle or <AuthView />. The native flow is one function call: startGoogleAuthenticationFlow(). No discovery object, no makeRedirectUri, no promptAsync.

From @react-native-google-signin/google-signin

Remove: @react-native-google-signin/google-signin, GoogleSignin.configure(), GoogleSignin.signIn(), manual token extraction, GoogleSignin.hasPlayServices().

Remove (if only used for Google auth): google-services.json, GoogleService-Info.plist, Firebase config. If you use Firebase for other features, keep these files.

Add: @clerk/expo, configure Clerk Dashboard with your existing Google Cloud credentials.

Benefits of switching:

  • No separate Google Sign-In library needed
  • No google-services.json or GoogleService-Info.plist config files (unless Firebase is needed for other features)
  • Session management, user profiles, and sign-out are built in
  • Credential Manager support included (the standalone library gates this behind a paid tier)

Note

Import path change from Core 2 to Core 3: The package renamed from @clerk/clerk-expo to @clerk/expo. Hooks import from @clerk/expo/google, native components from @clerk/expo/native.

Clerk vs. Other Expo Authentication Solutions

If you want...Best fitWhy
Browser-based OAuth that works in Expo Goexpo-auth-sessionNo native build required, but you keep redirect complexity
Full DIY native Google Sign-In@react-native-google-signinNative provider control, but you still own session management
Native Google Sign-In plus managed authClerkNative provider flow plus Clerk-managed sessions, users, and profile UI

Clerk's advantage isn't just the native Google UI. It's that the token exchange, session creation, session refresh, and user management happen automatically. The other approaches give you a Google ID token and leave the rest to you.

Key Takeaways

  • Native Google Sign-In in Expo doesn't need a browser. Clerk uses Credential Manager on Android (always native) and ASAuthorization on iOS (when EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME is configured). Without the iOS URL scheme, iOS falls back to a system browser sheet.
  • Two approaches: <AuthView /> for zero-code auth, useSignInWithGoogle for custom UI. Both use the same native flow under the hood.
  • Certificate fingerprint management is the hardest part. Debug, EAS, and production builds each have different fingerprints. Register SHA-1 in Google Cloud Console and SHA-256 in the Clerk Dashboard for each.
  • Expo Go can't run native sign-in. Use development builds from the start.
  • Clerk handles the full auth lifecycle. Sign-in, sign-up, transfer flow, session management, user profiles, and sign-out are included.

Get started: Expo Quickstart | Sign in with Google Guide | clerk-expo-quickstart examples

Frequently Asked Questions