
Expo Google Sign-In Without a WebView: The Native Approach Using Clerk
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-sessionimplementations (expo/expo#38666). - The
auth.expo.ioproxy 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:
- Embedded WebView (blocked by Google since 2021)
- System browser via ASWebAuthenticationSession/Chrome Custom Tabs (what
expo-auth-sessiondoes) - 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).
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
Tools and Accounts You Need
- Node.js 20.9.0+
- Expo CLI (
npx expo) - EAS CLI (
npm install -g eas-cli) and an Expo account - A Clerk account
- A Google Cloud Console account
- iOS: Xcode 16+, Apple Developer account (for device testing)
- Android: Android Studio, physical device or emulator with Google Play Services
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/expo3.1+ - Native components (
AuthView,UserButton,UserProfileView) require Expo SDK 53+ - React 18 or 19
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.
To create a development build:
npx expo install expo-dev-client
npx expo run:iosOr use EAS Build:
eas build --profile development --platform iosHow to Add Google Sign-In to an Expo App Without a Browser Redirect
Three main approaches exist. Here's how they compare:
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).
Option 3: Clerk Native Google Sign-In (Recommended)
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/expowith its peer dependencies (expo-secure-store,expo-auth-session,expo-web-browser) plusexpo-cryptofor the hook approach. AuthView doesn't needexpo-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
- Go to the Clerk Dashboard and create a new application (or select an existing one).
- Navigate to the Native Applications page and confirm that Native API is enabled.
- 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
- In the Clerk Dashboard, go to Social Connections > Google > Use custom credentials.
- You'll configure the Client IDs here after creating them in Google Cloud Console (next step).
- On the Native Applications page:
- iOS: Add your Team ID and Bundle ID (must match
ios.bundleIdentifierinapp.config.ts) - Android: Add your package name (must match
android.packageinapp.config.ts) and SHA-256 certificate fingerprint
- iOS: Add your Team ID and Bundle ID (must match
Step 3: Create a Google Cloud Project and OAuth Credentials
OAuth Consent Screen
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
- Go to Google Cloud Console > APIs & Services > Credentials > Create OAuth Client ID
- Application type: iOS
- Bundle ID: must match your
app.config.tsios.bundleIdentifierexactly - 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_SCHEMEfor the native callback path)
- The iOS Client ID (goes into
Android Client ID and SHA-1 Fingerprint
- Application type: Android
- Package name: must match your
app.config.tsandroid.package - 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 androidFor production, find the App Signing key SHA-1 in Google Play Console > Release > Setup > App Integrity.
- 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.
Configuration Validation Checklist
Before your first build, confirm:
- Bundle ID matches in Google Cloud Console, Clerk Native Applications, and
app.config.ts - Android package name matches in Google Cloud Console, Clerk Native Applications, and
app.config.ts - Web, iOS, and Android Client IDs are in the correct environment variables
- SHA-1 is registered in Google Cloud Console for each Android signing identity
- SHA-256 is registered in the Clerk Dashboard Native Applications page for each Android signing identity
- iOS reversed client ID is used as
EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME - 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-signinInstalling 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-clientFor the AuthView approach (pre-built native UI):
npx expo install @clerk/expo expo-secure-store expo-auth-session expo-web-browser expo-dev-clientAuthView 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 configThe @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.
// 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 onlysignUp: sign-up flows onlysignInOrUp: 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 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 () => {
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 },
})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:
- 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 \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_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 gates this behind a paid tier)
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