
Migrating from @clerk/clerk-expo to @clerk/expo: Breaking Changes, Native Components, and the Complete Upgrade Path
The @clerk/clerk-expo package is deprecated. Its replacement, @clerk/expo, ships with Clerk Core 3: native components powered by SwiftUI and Jetpack Compose, platform-native OAuth, passkey support, offline resilience, and a redesigned authentication hook API. The upgrade saves roughly 50KB gzipped through shared React internals (Core 3 Changelog, 2026-03-03).
This guide walks through the complete migration. It covers every breaking change, provides before and after code for each, and introduces the new features available after upgrading.
Core 2 is in long-term support until January 2027 (Versioning docs). You're not forced to migrate today. But @clerk/clerk-expo won't receive new features, and the bundle savings plus native component support make this upgrade worth prioritizing.
Who this guide is for: Expo developers with existing Clerk implementations, from novice to experienced.
How it's structured:
- Prerequisites and compatibility requirements
- Automated upgrade with the Clerk CLI
- Step-by-step migration (12 steps covering every breaking change)
- New feature adoption (native components, passkeys, offline support)
- Testing and validation checklists
Each migration step includes version-tagged code examples and standardized headers. Code samples use @clerk/expo >=3.0.0 unless otherwise noted.
Prerequisites and Compatibility Requirements
Minimum Version Requirements
If you're on an older Expo SDK, upgrade first. Follow the Expo SDK upgrade walkthrough to reach SDK 53+.
Three Authentication Approaches
@clerk/expo supports 3 approaches. Choose based on your requirements:
Development Build Requirement
Native features (AuthView, UserButton, native OAuth, passkeys) require a development build. Expo Go can't load custom native code.
Create a development build:
npx expo run:iosOr for Android:
npx expo run:androidFor CI/CD, use EAS Build.
Clerk Dashboard Configuration
Before migrating, configure your Clerk Dashboard:
- Enable Native API on the Native Applications page (deployment guide)
- Register your apps: iOS (Team ID + Bundle ID), Android (package name)
- Configure OAuth credentials for Google and Apple sign-in if using native OAuth
- Set up domains for passkeys and OAuth redirects
Step 1: Run the Clerk Upgrade CLI
Start with the automated migration tool. It handles the most common changes through AST-level code transforms.
npx @clerk/upgradeOther package managers:
pnpm dlx @clerk/upgrade
# or
yarn dlx @clerk/upgrade
# or
bunx @clerk/upgradeThe CLI supports --sdk and --dir flags for targeted scanning in monorepos.
What the CLI Handles
- Package rename:
@clerk/clerk-expoto@clerk/expo - Import path updates across all files
SignedIn,SignedOut,ProtecttoShowcomponent replacementsClerkProviderpositioning- Re-exports, aliased imports, and monorepo files
Review CLI Output
After running the CLI, review its output for warnings. The tool uses regex-based scanning and may miss unusual import patterns, bound methods, or indirect calls. Verify that custom wrappers or re-exports in your codebase were caught.
Step 2: Package Rename and Import Path Updates
Install the New Package
Remove @clerk/clerk-expo and install its replacement:
npx expo install @clerk/expo expo-secure-storeFor native components, add development dependencies:
npx expo install expo-auth-session expo-web-browser expo-dev-clientImport Path Reference Table
Every import from @clerk/clerk-expo changes to @clerk/expo or one of its 14 subpath exports:
Before (Core 2):
import { useAuth, useUser, SignedIn, SignedOut } from '@clerk/clerk-expo'After (Core 3, @clerk/expo >=3.0.0):
import { useAuth, useUser, Show } from '@clerk/expo'Removed Exports
Clerkexport removed. UseuseClerk()inside components orgetClerkInstance()outside them.@clerk/typesdeprecated. Types are now exported from SDK packages via@clerk/shared/types.@clerk/expo/secure-storedeprecated. Use@clerk/expo/resource-cacheinstead.
Step 3: ClerkProvider Configuration Changes
publishableKey Is Now Required
This is a breaking change. The publishable key must be passed explicitly to ClerkProvider.
Why? Environment variables inside node_modules aren't inlined during React Native production builds. Without the explicit prop, your app will crash in production. The publishable key encodes the Frontend API URL in base64 (How Clerk Works).
Before (Core 2):
import { ClerkProvider } from '@clerk/clerk-expo'
import { Slot } from 'expo-router'
export default function RootLayout() {
return (
<ClerkProvider>
<Slot />
</ClerkProvider>
)
}After (Core 3, @clerk/expo >=3.0.0):
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>
)
}Token Cache with expo-secure-store
Without tokenCache, Clerk stores tokens in memory. They're lost when the app restarts, forcing users to sign in again.
The tokenCache from @clerk/expo/token-cache uses expo-secure-store with AFTER_FIRST_UNLOCK keychain accessibility for encrypted persistent storage.
Install if you haven't already:
npx expo install expo-secure-storeapp.json Plugin Configuration
The @clerk/expo config plugin automatically adds the native SDKs (clerk-ios and clerk-android) and configures required build settings.
{
"expo": {
"plugins": [
"expo-secure-store",
[
"@clerk/expo",
{
"appleSignIn": true
}
]
]
}
}Plugin options:
The plugin handles these automatically:
- iOS: Adds
clerk-iosvia SPM (ClerkKit + ClerkKitUI), injectsClerkViewFactory.swift, modifiesAppDelegate.swift - Android: Adds META-INF exclusions, Kotlin metadata version flags
- Google Sign-In: Reads
EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEMEfor the iOS URL scheme
Step 4: Control Component Migration: SignedIn, SignedOut, Protect to Show
The <Show> component replaces 3 separate components: <SignedIn>, <SignedOut>, and <Protect>. It handles both authentication state checks and authorization (role-based access control) in a single API.
Authentication State Checks
Before (Core 2):
import { SignedIn, SignedOut } from '@clerk/clerk-expo'
import { Text } from 'react-native'
export default function HomeScreen() {
return (
<>
<SignedIn>
<Text>Welcome back!</Text>
</SignedIn>
<SignedOut>
<Text>Please sign in.</Text>
</SignedOut>
</>
)
}After (Core 3, @clerk/expo >=3.0.0):
import { Show } from '@clerk/expo'
import { Text } from 'react-native'
export default function HomeScreen() {
return (
<>
<Show when="signed-in">
<Text>Welcome back!</Text>
</Show>
<Show when="signed-out">
<Text>Please sign in.</Text>
</Show>
</>
)
}Authorization Checks
<Protect> with role/permission props becomes <Show> with object-based when:
Before (Core 2):
import { Protect } from '@clerk/clerk-expo'
import { Text } from 'react-native'
export default function AdminPanel() {
return (
<Protect role="org:admin" fallback={<Text>Not authorized</Text>}>
<Text>Admin panel content</Text>
</Protect>
)
}After (Core 3, @clerk/expo >=3.0.0):
import { Show } from '@clerk/expo'
import { Text } from 'react-native'
export default function AdminPanel() {
return (
<Show when={{ role: 'org:admin' }} fallback={<Text>Not authorized</Text>}>
<Text>Admin panel content</Text>
</Show>
)
}All Show Component when Patterns
treatPendingAsSignedOut
The treatPendingAsSignedOut prop (defaults to true) controls how pending sessions are treated. When using native components, set it to false to prevent the pending session state from showing as signed out during native-to-JS session sync.
Two places to set this:
import { Show, useAuth } from '@clerk/expo'
import { Text } from 'react-native'
// On the Show component
function NativeAwareShow() {
return (
<Show treatPendingAsSignedOut={false} when="signed-in">
<Text>Content</Text>
</Show>
)
}
// On the useAuth hook
function NativeAwareHook() {
const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false })
// ...
}Step 5: Hook API Changes
This is the largest manual migration step. The @clerk/upgrade CLI doesn't automate these changes because they require understanding your authentication flow logic.
useSignIn: Before and After
Core 3 replaces the imperative signIn.create() + setActive() pattern with method-specific APIs, structured errors, and fetchStatus tracking.
Before (Core 2):
import { useSignIn } from '@clerk/clerk-expo'
import { useState } from 'react'
import { Text, TextInput, Button, View } from 'react-native'
import { useRouter } from 'expo-router'
export default function SignInScreen() {
const { signIn, setActive, isLoaded } = useSignIn()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const router = useRouter()
const onSignIn = async () => {
if (!isLoaded) return
try {
const result = await signIn.create({
identifier: email,
password,
})
if (result.status === 'complete') {
await setActive({ session: result.createdSessionId })
router.replace('/(home)')
}
} catch (err: any) {
setError(err.errors?.[0]?.message || 'Sign in failed')
}
}
return (
<View>
<TextInput value={email} onChangeText={setEmail} placeholder="Email" />
<TextInput value={password} onChangeText={setPassword} secureTextEntry />
{error ? <Text>{error}</Text> : null}
<Button title="Sign In" onPress={onSignIn} />
</View>
)
}After (Core 3, @clerk/expo >=3.0.0):
import { useSignIn } from '@clerk/expo'
import { useState } from 'react'
import { Text, TextInput, Pressable, View } from 'react-native'
import { useRouter, type Href } from 'expo-router'
export default function SignInScreen() {
const { signIn, errors, fetchStatus } = useSignIn()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [mfaCode, setMfaCode] = useState('')
const router = useRouter()
const onSignIn = async () => {
await signIn.password({ emailAddress: email, password })
if (signIn.status === 'needs_second_factor') {
await signIn.mfa.sendEmailCode()
return
}
if (signIn.status === 'needs_client_trust') {
await signIn.mfa.sendEmailCode()
return
}
if (signIn.status === 'complete') {
await signIn.finalize({
navigate: ({ session, decorateUrl }) => {
if (session?.currentTask) return
router.push(decorateUrl('/') as Href)
},
})
}
}
const onVerifyMfa = async () => {
await signIn.mfa.verifyEmailCode({ code: mfaCode })
if (signIn.status === 'complete') {
await signIn.finalize({
navigate: ({ session, decorateUrl }) => {
if (session?.currentTask) return
router.push(decorateUrl('/') as Href)
},
})
}
}
return (
<View>
<TextInput value={email} onChangeText={setEmail} placeholder="Email" />
<TextInput value={password} onChangeText={setPassword} secureTextEntry />
{errors?.fields?.identifier ? <Text>{errors.fields.identifier.message}</Text> : null}
{errors?.fields?.password ? <Text>{errors.fields.password.message}</Text> : null}
{signIn.status === 'needs_second_factor' || signIn.status === 'needs_client_trust' ? (
<>
<TextInput value={mfaCode} onChangeText={setMfaCode} placeholder="Verification code" />
{errors?.fields?.code ? <Text>{errors.fields.code.message}</Text> : null}
<Pressable onPress={onVerifyMfa} disabled={fetchStatus === 'fetching'}>
<Text>Verify</Text>
</Pressable>
</>
) : (
<Pressable onPress={onSignIn} disabled={fetchStatus === 'fetching'}>
<Text>Sign In</Text>
</Pressable>
)}
</View>
)
}Key changes to notice:
- Return type:
{ signIn, errors, fetchStatus }replaces{ isLoaded, signIn, setActive } - Method-specific calls:
signIn.password()replacessignIn.create({ identifier, password }) - Structured errors:
errors.fields.identifier?.messagereplacestry/catchwitherr.errors?.[0]?.message - fetchStatus:
'idle'or'fetching', useful for disabling buttons during API calls - finalize replaces setActive:
signIn.finalize({ navigate })replacessetActive({ session }) needs_client_trust: New status for credential stuffing protection. Triggers on new devices with valid password and no MFA enabled. Auto-enabled for apps created after November 14, 2025 (Client Trust, 2025-11-14). Only affects password-based sign-ins.
useSignUp: Before and After
Before (Core 2):
import { useSignUp } from '@clerk/clerk-expo'
import { useState } from 'react'
import { Text, TextInput, Button, View } from 'react-native'
import { useRouter } from 'expo-router'
export default function SignUpScreen() {
const { signUp, setActive, isLoaded } = useSignUp()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [code, setCode] = useState('')
const [pendingVerification, setPendingVerification] = useState(false)
const router = useRouter()
const onSignUp = async () => {
if (!isLoaded) return
try {
await signUp.create({ emailAddress: email, password })
await signUp.prepareEmailAddressVerification({ strategy: 'email_code' })
setPendingVerification(true)
} catch (err: any) {
console.error(err.errors?.[0]?.message)
}
}
const onVerify = async () => {
try {
const result = await signUp.attemptEmailAddressVerification({ code })
if (result.status === 'complete') {
await setActive({ session: result.createdSessionId })
router.replace('/(home)')
}
} catch (err: any) {
console.error(err.errors?.[0]?.message)
}
}
return (
<View>
{pendingVerification ? (
<>
<TextInput value={code} onChangeText={setCode} placeholder="Verification code" />
<Button title="Verify" onPress={onVerify} />
</>
) : (
<>
<TextInput value={email} onChangeText={setEmail} placeholder="Email" />
<TextInput value={password} onChangeText={setPassword} secureTextEntry />
<Button title="Sign Up" onPress={onSignUp} />
</>
)}
</View>
)
}After (Core 3, @clerk/expo >=3.0.0):
import { useSignUp } from '@clerk/expo'
import { useState } from 'react'
import { Text, TextInput, Pressable, View } from 'react-native'
import { useRouter, type Href } from 'expo-router'
export default function SignUpScreen() {
const { signUp, errors, fetchStatus } = useSignUp()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [code, setCode] = useState('')
const router = useRouter()
const onSignUp = async () => {
await signUp.password({ emailAddress: email, password })
if (
signUp.status === 'missing_requirements' &&
signUp.unverifiedFields.includes('email_address')
) {
await signUp.verifications.sendEmailCode()
}
}
const onVerify = async () => {
await signUp.verifications.verifyEmailCode({ code })
if (signUp.status === 'complete') {
await signUp.finalize({
navigate: ({ session, decorateUrl }) => {
if (session?.currentTask) return
router.push(decorateUrl('/') as Href)
},
})
}
}
return (
<View>
{signUp.status === 'missing_requirements' &&
signUp.unverifiedFields.includes('email_address') ? (
<>
<TextInput value={code} onChangeText={setCode} placeholder="Verification code" />
{errors?.fields?.code ? <Text>{errors.fields.code.message}</Text> : null}
<Pressable onPress={onVerify} disabled={fetchStatus === 'fetching'}>
<Text>Verify Email</Text>
</Pressable>
</>
) : (
<>
<TextInput value={email} onChangeText={setEmail} placeholder="Email" />
<TextInput value={password} onChangeText={setPassword} secureTextEntry />
{errors?.fields?.emailAddress ? <Text>{errors.fields.emailAddress.message}</Text> : null}
{errors?.fields?.password ? <Text>{errors.fields.password.message}</Text> : null}
<Pressable onPress={onSignUp} disabled={fetchStatus === 'fetching'}>
<Text>Sign Up</Text>
</Pressable>
<View nativeID="clerk-captcha" />
</>
)}
</View>
)
}setActive Callback Changes
The beforeEmit callback is replaced by navigate. The new callback receives session and decorateUrl:
Before (Core 2):
await setActive({
session: result.createdSessionId,
beforeEmit: (session) => {
router.push('/(home)')
},
})After (Core 3, @clerk/expo >=3.0.0):
await signIn.finalize({
navigate: ({ session, decorateUrl }) => {
if (session?.currentTask) return
router.push(decorateUrl('/') as Href)
},
})Always wrap destination URLs with decorateUrl(). Check session?.currentTask before navigating. If a task exists (like an organization invitation), the SDK handles routing.
useAuth, useUser, useClerk, useSession
Import paths changed, but the API is largely the same. One change: useAuth().getToken is now always a function (never undefined). Use try/catch instead of conditional checks.
Before (Core 2):
import { useAuth } from '@clerk/clerk-expo'
async function useApiToken() {
const { getToken } = useAuth()
const token = getToken ? await getToken() : null
}After (Core 3, @clerk/expo >=3.0.0):
import { useAuth } from '@clerk/expo'
async function useApiToken() {
const { getToken } = useAuth()
const token = await getToken() // always a function, use try/catch for errors
}Legacy Import Path
For large codebases, @clerk/expo/legacy provides the old Core 2 useSignIn/useSignUp API as a stepping stone. You can rename the package first, then refactor auth flows later.
// Core 2 API from the new package. Will be removed in a future release.
import { useSignIn } from '@clerk/expo/legacy'The legacy API will be removed in a future release. Plan to migrate to the updated authentication hook API.
Step 6: Appearance and Theming Changes
Configuration Restructuring
appearance.layout is renamed to appearance.options:
Before (Core 2):
<ClerkProvider
appearance={{
layout: {
showOptionalFields: true,
},
}}
></ClerkProvider>After (Core 3, @clerk/expo >=3.0.0):
<ClerkProvider
appearance={{
options: {
showOptionalFields: false,
},
}}
></ClerkProvider>Other appearance changes:
showOptionalFieldsdefault changed fromtruetofalse. Set it explicitly if you want optional fields visible.colorRingandcolorModalBackdropnow render at full opacity. Usergba()values to restore previous behavior.- Experimental prefixes standardized. All
experimental_andexperimental__prefixes are now__experimental_. Update any custom theme configuration. - Automatic light/dark theming. Components match your app's color scheme without manual configuration.
Step 7: Deprecation Removals and Renamed APIs
Redirect Prop Changes
Before (Core 2):
<ClerkProvider afterSignInUrl="/(home)" afterSignUpUrl="/(home)"></ClerkProvider>After (Core 3, @clerk/expo >=3.0.0):
<ClerkProvider
signInFallbackRedirectUrl="/(home)"
signUpFallbackRedirectUrl="/(home)"
></ClerkProvider>SAML to Enterprise SSO
SAML references are renamed to enterprise SSO throughout the API:
Before (Core 2):
// Core 2 SAML references
const samlAccounts = user.samlAccounts
await signIn.create({ strategy: 'saml', identifier: email })After (Core 3, @clerk/expo >=3.0.0):
// Core 3 enterprise SSO references
const enterpriseAccounts = user.enterpriseAccountsIn Expo, use useSSO() with the renamed strategy for enterprise SSO flows:
import { useSSO } from '@clerk/expo'
export function EnterpriseSSOButton({ email }: { email: string }) {
const { startSSOFlow } = useSSO()
const onPress = async () => {
const { createdSessionId, setActive } = await startSSOFlow({
strategy: 'enterprise_sso',
identifier: email,
})
if (createdSessionId && setActive) {
await setActive({ session: createdSessionId })
}
}
// render button...
}useOAuth to useSSO
The useOAuth() hook is deprecated. Use useSSO() for browser-based SSO and OAuth flows:
Before (Core 2):
import { useOAuth } from '@clerk/clerk-expo'
import * as WebBrowser from 'expo-web-browser'
WebBrowser.maybeCompleteAuthSession()
export function GoogleOAuthButton() {
const { startOAuthFlow } = useOAuth({ strategy: 'oauth_google' })
const onPress = async () => {
const { createdSessionId, setActive } = await startOAuthFlow()
if (createdSessionId && setActive) {
await setActive({ session: createdSessionId })
}
}
// render button...
}After (Core 3, @clerk/expo >=3.0.0):
import { useSSO } from '@clerk/expo'
export function GoogleSSOButton() {
const { startSSOFlow } = useSSO()
const onPress = async () => {
const { createdSessionId, setActive } = await startSSOFlow({
strategy: 'oauth_google',
redirectUrl: 'your-scheme://callback',
})
if (createdSessionId && setActive) {
await setActive({ session: createdSessionId })
}
}
// render button...
}Other Renamed APIs
Step 8: Adopting Native Components (Beta)
Native components are the biggest addition in @clerk/expo 3.1. They render authentication UI using SwiftUI on iOS and Jetpack Compose on Android (Expo Native Components, 2026-03-09).
AuthView: Native Authentication Interface
AuthView renders a complete sign-in/sign-up flow using platform-native UI. It handles email/password, social login, passkeys, and MFA automatically.
import { AuthView } from '@clerk/expo/native'
import { useAuth } from '@clerk/expo'
import { useRouter } from 'expo-router'
import { useEffect } from 'react'
export default function SignInScreen() {
const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false })
const router = useRouter()
useEffect(() => {
if (isSignedIn) {
router.replace('/(home)')
}
}, [isSignedIn])
return <AuthView mode="signInOrUp" />
}Props:
AuthView handles social sign-in flows automatically. You don't need useSignInWithGoogle or useSignInWithApple hooks (or their peer dependencies like expo-crypto) when using AuthView.
UserButton: Native Profile Avatar
UserButton displays the user's avatar as a tappable circle. Tapping opens a native profile modal.
import { Stack } from 'expo-router'
import { UserButton } from '@clerk/expo/native'
import { View } from 'react-native'
export default function HomeLayout() {
return (
<Stack
screenOptions={{
headerRight: () => (
<View style={{ width: 36, height: 36, borderRadius: 18, overflow: 'hidden' }}>
<UserButton />
</View>
),
}}
>
<Stack.Screen name="index" options={{ title: 'Home' }} />
</Stack>
)
}UserButton has no props. The parent container controls its size and shape. Sign-out is handled automatically and synced with the JS SDK.
UserProfileView: Full Profile Management
For a full user profile management screen, use the useUserProfileModal() hook:
import { useUserProfileModal } from '@clerk/expo'
import { Pressable, Text } from 'react-native'
export default function ProfileScreen() {
const { presentUserProfile } = useUserProfileModal()
return (
<Pressable onPress={() => presentUserProfile()}>
<Text>Manage Profile</Text>
</Pressable>
)
}The modal provides personal info, security settings, account switching, MFA, passkeys, connected accounts, and sign-out.
Session Synchronization
Native components use a separate native SDK. The NativeSessionSync component inside ClerkProvider handles bidirectional sync:
- Native auth completes and creates a session
- Bearer token syncs to the native SDK
- JS SDK picks up the session
- React hooks reflect the new auth state
Use useEffect to react to auth state changes. Don't use imperative callbacks. Always set treatPendingAsSignedOut to false with native components to avoid a flash of signed-out content during sync.
Web Compatibility
For Expo web projects, use @clerk/expo/web which provides prebuilt web components (SignIn, SignUp, UserButton, etc.). These throw on native. Keep native and web paths separate with platform checks.
Step 9: Native Authentication Hooks
Google Sign-In Without a WebView
useSignInWithGoogle uses platform-native APIs: ASAuthorization on iOS, Credential Manager on Android. No browser redirect.
Install the required peer dependency:
npx expo install expo-cryptoConfigure 3 OAuth client IDs in the Google Cloud Console (iOS, Android, Web) and set them as environment variables (Google Sign-In guide).
import { useSignInWithGoogle } from '@clerk/expo/google'
import { useRouter } from 'expo-router'
import { Alert, Platform, Pressable, Text } from 'react-native'
export function GoogleSignInButton() {
if (Platform.OS !== 'ios' && Platform.OS !== 'android') return null
const { startGoogleAuthenticationFlow } = useSignInWithGoogle()
const router = useRouter()
const onPress = async () => {
try {
const { createdSessionId, setActive } = await startGoogleAuthenticationFlow()
if (createdSessionId && setActive) {
await setActive({ session: createdSessionId })
router.replace('/')
}
} catch (err: any) {
if (err.code === 'SIGN_IN_CANCELLED' || err.code === '-5') return
Alert.alert('Error', err.message || 'Google sign-in failed')
}
}
return (
<Pressable onPress={onPress}>
<Text>Sign in with Google</Text>
</Pressable>
)
}Apple Sign-In (iOS Only)
Install the required peer dependencies:
npx expo install expo-apple-authentication expo-cryptoRegister in the Clerk Dashboard with your Team ID + Bundle ID (Apple Sign-In guide).
import { useSignInWithApple } from '@clerk/expo/apple'
import { useRouter } from 'expo-router'
import { Alert, Platform, Pressable, Text } from 'react-native'
export function AppleSignInButton() {
if (Platform.OS !== 'ios') return null
const { startAppleAuthenticationFlow } = useSignInWithApple()
const router = useRouter()
const onPress = async () => {
try {
const { createdSessionId, setActive } = await startAppleAuthenticationFlow()
if (createdSessionId && setActive) {
await setActive({ session: createdSessionId })
router.replace('/')
}
} catch (err: any) {
if (err.code === 'ERR_REQUEST_CANCELED') return
Alert.alert('Error', err.message || 'Apple sign-in failed')
}
}
return (
<Pressable onPress={onPress}>
<Text>Sign in with Apple</Text>
</Pressable>
)
}Biometric Authentication with Local Credentials
useLocalCredentials enables biometric authentication (Face ID, fingerprint) for password-based sign-in. It stores encrypted credentials on-device after the first password sign-in.
Install the required peer dependencies:
npx expo install expo-local-authentication expo-secure-storeProperties: hasCredentials, userOwnsCredentials, biometricType ('face-recognition' | 'fingerprint' | null). Methods: setCredentials(), clearCredentials(), authenticate().
Workflow:
- User signs in with password
- Call
setCredentials()to store credentials - On future launches, call
authenticate()for biometric sign-in
import { useSignIn, useClerk } from '@clerk/expo'
import { useLocalCredentials } from '@clerk/expo/local-credentials'
import { useState } from 'react'
import { Text, TextInput, Pressable, View } from 'react-native'
import { useRouter, type Href } from 'expo-router'
export default function SignInScreen() {
const { signIn, errors, fetchStatus } = useSignIn()
const { setActive } = useClerk()
const { hasCredentials, setCredentials, authenticate, biometricType } = useLocalCredentials()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const router = useRouter()
// Biometric sign-in for returning users.
// authenticate() returns a SignInResource (Core 2 type), so use
// setActive() from useClerk() instead of signIn.finalize().
const onBiometricSignIn = async () => {
const result = await authenticate()
if (result.status === 'complete') {
await setActive({ session: result.createdSessionId })
router.replace('/')
}
}
// Password sign-in with credential storage (Core 3 authentication hook API)
const onPasswordSignIn = async () => {
await signIn.password({ emailAddress: email, password })
if (signIn.status === 'complete') {
// Store credentials for future biometric sign-in
await setCredentials({ identifier: email, password })
await signIn.finalize({
navigate: ({ session, decorateUrl }) => {
if (session?.currentTask) return
router.push(decorateUrl('/') as Href)
},
})
}
}
return (
<View>
{hasCredentials && biometricType ? (
<Pressable onPress={onBiometricSignIn} disabled={fetchStatus === 'fetching'}>
<Text>
{biometricType === 'face-recognition'
? 'Sign in with Face ID'
: 'Sign in with Fingerprint'}
</Text>
</Pressable>
) : null}
<TextInput value={email} onChangeText={setEmail} placeholder="Email" />
<TextInput value={password} onChangeText={setPassword} secureTextEntry />
{errors?.fields?.identifier ? <Text>{errors.fields.identifier.message}</Text> : null}
<Pressable onPress={onPasswordSignIn} disabled={fetchStatus === 'fetching'}>
<Text>Sign In</Text>
</Pressable>
</View>
)
}Step 10: Passkeys Configuration
Passkeys provide passwordless authentication using WebAuthn. This feature is experimental in @clerk/expo.
Installation
npx expo install @clerk/expo-passkeys expo-build-properties
npx expo prebuildEnable passkeys in your Clerk Dashboard's authentication settings. Then configure ClerkProvider:
import { ClerkProvider } from '@clerk/expo'
import { tokenCache } from '@clerk/expo/token-cache'
import { passkeys } from '@clerk/expo/passkeys'
import { Slot } from 'expo-router'
export default function RootLayout() {
return (
<ClerkProvider
publishableKey={process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!}
tokenCache={tokenCache}
__experimental_passkeys={passkeys}
>
<Slot />
</ClerkProvider>
)
}iOS Requirements
- iOS 16+ required for passkeys (Apple added passkey support in iOS 16)
- Set the iOS deployment target to 16.0 or higher manually with
expo-build-properties. The@clerk/expoconfig plugin does not set a deployment target automatically. - Register your app in Clerk Dashboard with App ID Prefix + Bundle ID (from Apple Developer portal's Identifiers page)
- Configure associated domains in
app.json:
{
"expo": {
"ios": {
"associatedDomains": [
"applinks:<YOUR_FRONTEND_API_URL>",
"webcredentials:<YOUR_FRONTEND_API_URL>"
]
},
"plugins": [["expo-build-properties", { "ios": { "deploymentTarget": "16.0" } }]]
}
}Android Requirements
- Android 9+ required
- Physical device only. Emulators don't support passkeys.
- Register in Clerk Dashboard with your package name and SHA256 certificate fingerprints
- Configure intent filters:
{
"expo": {
"android": {
"intentFilters": [
{
"action": "VIEW",
"autoVerify": true,
"data": [{ "scheme": "https", "host": "<YOUR_FRONTEND_API_URL>" }],
"category": ["BROWSABLE", "DEFAULT"]
}
]
}
}
}Verify setup with Google's Statement List Generator tool.
Passkey Methods (Core 3)
Create a passkey:
import { useUser } from '@clerk/expo'
function CreatePasskeyButton() {
const { user } = useUser()
const onCreate = async () => {
await user.createPasskey()
}
// render button...
}Sign in with a passkey:
import { useSignIn } from '@clerk/expo'
import { useRouter, type Href } from 'expo-router'
function PasskeySignIn() {
const { signIn } = useSignIn()
const router = useRouter()
const onSignIn = async () => {
await signIn.passkey({ flow: 'discoverable' })
if (signIn.status === 'complete') {
await signIn.finalize({
navigate: ({ session, decorateUrl }) => {
if (session?.currentTask) return
router.push(decorateUrl('/') as Href)
},
})
}
}
// render button...
}Flow options: 'discoverable' (requires user interaction) or 'autofill' (prompts before interaction).
Step 11: Offline Support and ClerkOfflineError
Breaking Change: getToken() Behavior
In Core 2, getToken() returned null when offline. This was ambiguous: it could mean signed out or offline. Core 3 throws ClerkOfflineError after a ~15 second retry period, making the distinction explicit.
Before (Core 2):
import { useAuth } from '@clerk/clerk-expo'
function useApiClient() {
const { getToken } = useAuth()
const fetchData = async () => {
const token = await getToken()
if (!token) {
// Could be signed out OR offline. No way to tell.
return null
}
// make API call with token
}
}After (Core 3, @clerk/expo >=3.0.0):
import { useAuth } from '@clerk/expo'
import { ClerkOfflineError } from '@clerk/react/errors'
function useApiClient() {
const { getToken } = useAuth()
const fetchData = async () => {
try {
const token = await getToken()
if (!token) {
// Definitively signed out
return null
}
// make API call with token
} catch (error) {
if (ClerkOfflineError.is(error)) {
// Definitively offline. Show cached data or retry UI.
return null
}
throw error
}
}
}Expo's custom useAuth override adds JWT caching: if a network error occurs, it returns the cached token instead of throwing. This makes offline transitions smoother.
Experimental Offline Support
For full offline resilience, pass resourceCache to ClerkProvider. It caches authentication state, environment data, and session JWTs using expo-secure-store.
import { ClerkProvider } from '@clerk/expo'
import { tokenCache } from '@clerk/expo/token-cache'
import { resourceCache } from '@clerk/expo/resource-cache'
import { Slot } from 'expo-router'
export default function RootLayout() {
return (
<ClerkProvider
publishableKey={process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!}
tokenCache={tokenCache}
__experimental_resourceCache={resourceCache}
>
<Slot />
</ClerkProvider>
)
}The resource cache stores authentication state using expo-secure-store for encrypted persistent storage (Offline Support, 2024-12-12).
Token Refresh Strategy
Clerk uses a hybrid auth model: client tokens (long-lived, on the FAPI domain) and session tokens (60-second expiry, on the app domain). The SDK handles token refresh automatically in the background, so sessions stay valid without manual intervention (How Clerk Works). No code changes required.
Step 12: Expo Router Protected Routes
Layout-Based Route Protection
Use route groups with _layout.tsx files for authentication-based routing:
import { useAuth } from '@clerk/expo'
import { Redirect, Stack } from 'expo-router'
export default function HomeLayout() {
const { isSignedIn, isLoaded } = useAuth()
if (!isLoaded) return null
if (!isSignedIn) {
return <Redirect href="/(auth)/sign-in" />
}
return <Stack />
}The auth route layout redirects signed-in users away:
import { useAuth } from '@clerk/expo'
import { Redirect, Stack } from 'expo-router'
export default function AuthLayout() {
const { isSignedIn, isLoaded } = useAuth()
if (!isLoaded) return null
if (isSignedIn) {
return <Redirect href="/(home)" />
}
return <Stack />
}Authorization-Based Route Protection
Protect admin routes using <Show> with organization roles:
import { Show } from '@clerk/expo'
import { Stack } from 'expo-router'
import { Text } from 'react-native'
export default function AdminLayout() {
return (
<Show when={{ role: 'org:admin' }} fallback={<Text>Not authorized</Text>}>
<Stack />
</Show>
)
}Organizations and Multi-Tenant Authorization
Organizations in Core 3 use the same <Show> component for multi-tenant authorization checks.
Organization Authorization Patterns
import { Show } from '@clerk/expo'
import { Text, View } from 'react-native'
export default function Dashboard() {
return (
<View>
<Show when={{ role: 'org:admin' }}>
<Text>Admin panel: manage members and settings</Text>
</Show>
<Show when={{ permission: 'org:invoices:create' }}>
<Text>Create and manage invoices</Text>
</Show>
<Show when={{ feature: 'premium_access' }}>
<Text>Premium content for subscribers</Text>
</Show>
<Show when={{ plan: 'enterprise' }}>
<Text>Enterprise features: SSO, audit logs</Text>
</Show>
<Show
when={(has) => has({ role: 'org:admin' }) || has({ permission: 'org:billing:manage' })}
fallback={<Text>Access restricted</Text>}
>
<Text>Billing management</Text>
</Show>
</View>
)
}User Management
UserProfileView provides self-service user management including personal info, security settings, and account switching. Use the useUserProfileModal() hook for modal presentation or render UserProfileView inline from @clerk/expo/native.
For session management, the native SDK handles session lifecycle, switching, and sign-out automatically when using native components.
Testing and Validation
Pre-Migration Checklist
Run through this checklist after completing all migration steps:
- Ran
npx @clerk/upgradeCLI - Package renamed from
@clerk/clerk-expoto@clerk/expo - All import paths updated (see import reference table in Step 2)
publishableKeyexplicitly passed toClerkProvidertokenCachefrom@clerk/expo/token-cacheconfiguredapp.jsonplugins updated (@clerk/expo,expo-secure-store)SignedIn/SignedOut/Protectreplaced with<Show>- Hook API calls updated to Core 3 authentication API
- Environment variables updated to
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY - Redirect props renamed (
afterSignInUrltosignInFallbackRedirectUrl) useOAuthreplaced withuseSSOif applicable@clerk/typesimports moved to@clerk/shared/types
Testing Authentication Flows
Testing Authorization
- Verify
<Show>with role-based conditions shows/hides correctly - Verify
<Show>with permission-based conditions - Verify fallback content renders for unauthorized users
- Test organization switching and role changes in real-time
Testing Native Components
- AuthView renders and completes auth flow on iOS and Android
- UserButton displays avatar, opens profile modal
treatPendingAsSignedOut: falseis set onuseAuth()and<Show>- Session sync completes within ~3 seconds of native auth
Testing Offline and Error Handling
- Disable network, verify
ClerkOfflineErroris caught (not null) - Test biometric auth if using
useLocalCredentials - Test passkeys on physical devices (not emulators)