Skip to main content
Articles

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

Author: Roy Anger
Published: (last updated )

This is the second part of a two-part series on implementing native Google Sign-In in Expo apps. While the first part covered the foundational setup and using the pre-built <AuthView /> component, this part explores building a custom authentication UI using the useSignInWithGoogle hook, combining Google Sign-In with Email/OTP authentication, managing user profiles and sessions, and handling production deployments and errors.

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 () => {
    const { error } = await signUp.create({ emailAddress: email })
    if (error) {
      Alert.alert('Error', error.longMessage ?? 'Could not create account')
      return
    }

    const { error: sendError } = await signUp.verifications.sendEmailCode()
    if (sendError) {
      Alert.alert('Error', sendError.longMessage ?? 'Could not send code')
      return
    }

    setPendingVerification(true)
  }

  const handleVerify = async () => {
    const { error } = await signUp.verifications.verifyEmailCode({ code })
    if (error) {
      Alert.alert('Verification failed', error.longMessage ?? 'Invalid code')
      return
    }

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

  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 () => {
    const { error } = await signIn.emailCode.sendCode({ emailAddress: email })
    if (error) {
      Alert.alert('Error', error.longMessage ?? 'Could not send code')
      return
    }

    setPendingVerification(true)
  }

  const handleVerify = async () => {
    const { error } = await signIn.emailCode.verifyCode({ code })
    if (error) {
      Alert.alert('Verification failed', error.longMessage ?? 'Invalid code')
      return
    }

    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')
    }
  }

  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.

These Core 3 sign-in and sign-up methods resolve to an { error } object instead of throwing, so check error after each call rather than wrapping them in try/catch. (The native useSignInWithGoogle() hook is the exception — it throws, so keep its try/catch.) Calling finalize() converts a completed sign-in or sign-up into the active session and updates anything observing auth state, such as useUser() and the <Show> components. It is the Core 3 replacement for the older setActive() pattern in these custom flows.

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" treatPendingAsSignedOut={false} 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"
      treatPendingAsSignedOut={false}
      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. When you read auth state with useAuth() alongside native components, pass { treatPendingAsSignedOut: false }useAuth({ treatPendingAsSignedOut: false }) — so a pending session task (for example, an unselected organization) isn't treated as signed-out. 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: SHA-1 Fingerprint Mismatch

A common configuration failure. The root cause is a certificate fingerprint mismatch, but the symptom depends on which library you use. With Clerk's native Google Sign-In — backed by Android Credential Manager through the clerk-android SDK — a mismatch does not surface as the legacy DEVELOPER_ERROR / code 10 you may have hit with @react-native-google-signin/google-signin. Credential Manager fails to return a Google ID token and throws a GetCredentialException (commonly NoCredentialException), which Clerk surfaces as a "No Google account available" error. The account chooser may not appear, or it appears and then reports no usable account, instead of completing sign-in.

Note

DEVELOPER_ERROR with code 10 is specific to the legacy Google Play Services Sign-In API (and the @react-native-google-signin/google-signin library that wraps it). If you see a literal code 10, you are on that path, not @clerk/expo's native flow.

Root cause: the SHA-1 registered in Google Cloud Console (or the SHA-256 in the Clerk Dashboard) 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 > Release > Setup > 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 ships Credential Manager only in its paid Universal Sign In tier; the free module uses the deprecated legacy Google Sign-In SDK)

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. Native Google Sign-In and the native components shipped in @clerk/expo 3.1 and require Expo SDK 53 or later.

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

In this series

  1. Expo Google Sign-In Without a WebView: The Native Approach Using Clerk
  2. Expo Google Sign-In Without a WebView: The Native Approach Using Clerk - Part 2 (you are here)