
Expo Google Sign-In Without a WebView: The Native Approach Using Clerk - Part 2
Part 2 of 2. Start with Expo Google Sign-In Without a WebView: The Native Approach Using Clerk.
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 succeededsetActive: function to activate the sessionsignIn/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
ASAuthorizationsystem 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 },
})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.
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:
- Debug keystore (local
npx expo run:android):
keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android- EAS managed keystore (cloud builds):
eas credentials --platform android- 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_IDdoesn't match the Google Cloud Console iOS Client IDios.bundleIdentifierinapp.config.tsdoesn't match what's registered in Google Cloud ConsoleEXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEMEisn'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.
Deep Link and Bundle Identifier Issues
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.tsdoesn'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.jsonrequired. 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 iosSet 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
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.jsonorGoogleService-Info.plistconfig 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)
Clerk vs. Other Expo Authentication Solutions
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
ASAuthorizationon iOS (whenEXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEMEis configured). Without the iOS URL scheme, iOS falls back to a system browser sheet. - Two approaches:
<AuthView />for zero-code auth,useSignInWithGooglefor 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
- Expo Google Sign-In Without a WebView: The Native Approach Using Clerk
- Expo Google Sign-In Without a WebView: The Native Approach Using Clerk - Part 2 (you are here)