Skip to main content
Articles

Migrating from @clerk/clerk-expo to @clerk/expo: Breaking Changes, Native Components, and the Complete Upgrade Path

Author: Roy Anger
Published:

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:

  1. Prerequisites and compatibility requirements
  2. Automated upgrade with the Clerk CLI
  3. Step-by-step migration (12 steps covering every breaking change)
  4. New feature adoption (native components, passkeys, offline support)
  5. 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

DependencyMinimum VersionNotes
Expo SDK53Peer dep >=53 <56
React Native0.73.0
React18.0.0 or 19.0.0Peer dep ^18.0.0 || ^19.0.0
Node.js20.9.0
@clerk/expo3.0.0Latest: 3.1.6 (April 2026)
iOS (passkeys only)16.0Set manually via expo-build-properties

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:

ApproachAuth UIOAuthRequires Dev BuildBest For
JavaScript onlyCustom React Native flowsBrowser-based (useSSO)No (works in Expo Go)Full UI control
JS + Native Sign-inCustom flows + native OAuth buttonsNative (no browser)YesCustom UI with native Google/Apple
Native Components (beta)Pre-built native UI (AuthView)Native (no browser)YesFastest integration

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:ios

Or for Android:

npx expo run:android

For CI/CD, use EAS Build.

Clerk Dashboard Configuration

Before migrating, configure your Clerk Dashboard:

  1. Enable Native API on the Native Applications page (deployment guide)
  2. Register your apps: iOS (Team ID + Bundle ID), Android (package name)
  3. Configure OAuth credentials for Google and Apple sign-in if using native OAuth
  4. 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/upgrade

Other package managers:

pnpm dlx @clerk/upgrade
# or
yarn dlx @clerk/upgrade
# or
bunx @clerk/upgrade

The CLI supports --sdk and --dir flags for targeted scanning in monorepos.

What the CLI Handles

  • Package rename: @clerk/clerk-expo to @clerk/expo
  • Import path updates across all files
  • SignedIn, SignedOut, Protect to Show component replacements
  • ClerkProvider positioning
  • Re-exports, aliased imports, and monorepo files

Warning

The CLI does not handle these changes. You'll need to make them manually:

  • app.json plugin configuration
  • Environment variable updates (EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY)
  • Core 3 authenication hook API refactoring (useSignIn/useSignUp hook body changes)
  • Token cache configuration (@clerk/expo/token-cache)
  • Offline error handling (ClerkOfflineError)
  • useOAuth to useSSO migration
  • Native component adoption

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-store

For native components, add development dependencies:

npx expo install expo-auth-session expo-web-browser expo-dev-client

Import Path Reference Table

Every import from @clerk/clerk-expo changes to @clerk/expo or one of its 14 subpath exports:

FeatureOld ImportNew Import
Core hooks@clerk/clerk-expo@clerk/expo
Control components@clerk/clerk-expo (SignedIn, SignedOut, Protect)@clerk/expo (Show)
Native componentsN/A (new)@clerk/expo/native
Token cacheCustom implementation@clerk/expo/token-cache
Resource cacheN/A (new)@clerk/expo/resource-cache
PasskeysN/A (new)@clerk/expo/passkeys
Error typesN/A (new)@clerk/react/errors
Apple Sign-In@clerk/clerk-expo@clerk/expo/apple
Google Sign-In@clerk/clerk-expo@clerk/expo/google
Web components@clerk/clerk-expo/web@clerk/expo/web
Local credentials@clerk/clerk-expo@clerk/expo/local-credentials
Legacy hooksN/A@clerk/expo/legacy
Types@clerk/types@clerk/shared/types

Before (Core 2):

app/example.tsx
import { useAuth, useUser, SignedIn, SignedOut } from '@clerk/clerk-expo'

After (Core 3, @clerk/expo >=3.0.0):

app/example.tsx
import { useAuth, useUser, Show } from '@clerk/expo'

Removed Exports

  • Clerk export removed. Use useClerk() inside components or getClerkInstance() outside them.
  • @clerk/types deprecated. Types are now exported from SDK packages via @clerk/shared/types.
  • @clerk/expo/secure-store deprecated. Use @clerk/expo/resource-cache instead.

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):

app/_layout.tsx
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):

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

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-store

app.json Plugin Configuration

The @clerk/expo config plugin automatically adds the native SDKs (clerk-ios and clerk-android) and configures required build settings.

app.json
{
  "expo": {
    "plugins": [
      "expo-secure-store",
      [
        "@clerk/expo",
        {
          "appleSignIn": true
        }
      ]
    ]
  }
}

Plugin options:

OptionTypeDefaultDescription
appleSignInbooleantrueAdds Apple Sign-In entitlement
keychainServicestringundefinedFor extension targets sharing keychain

The plugin handles these automatically:

  • iOS: Adds clerk-ios via SPM (ClerkKit + ClerkKitUI), injects ClerkViewFactory.swift, modifies AppDelegate.swift
  • Android: Adds META-INF exclusions, Kotlin metadata version flags
  • Google Sign-In: Reads EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME for 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.

Note

<Show> only visually hides content. The underlying views remain accessible to inspection. For sensitive data, always perform server-side authorization checks.

Authentication State Checks

Before (Core 2):

app/home.tsx
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):

app/home.tsx
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):

app/admin.tsx
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):

app/admin.tsx
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

PatternExampleCore 2 Equivalent
Signed inwhen="signed-in"<SignedIn>
Signed outwhen="signed-out"<SignedOut>
Rolewhen={{ role: 'org:admin' }}<Protect role="org:admin">
Permissionwhen={{ permission: 'org:invoices:create' }}<Protect permission="...">
Feature (new)when={{ feature: 'premium_access' }}N/A
Plan (new)when={{ plan: 'bronze' }}N/A
Custom logicwhen={(has) => has({ role: 'org:admin' })}<Protect condition={(has) => ...}>

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:

app/native-example.tsx
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):

app/(auth)/sign-in.tsx
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):

app/(auth)/sign-in.tsx
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() replaces signIn.create({ identifier, password })
  • Structured errors: errors.fields.identifier?.message replaces try/catch with err.errors?.[0]?.message
  • fetchStatus: 'idle' or 'fetching', useful for disabling buttons during API calls
  • finalize replaces setActive: signIn.finalize({ navigate }) replaces setActive({ 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):

app/(auth)/sign-up.tsx
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):

app/(auth)/sign-up.tsx
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>
  )
}

Important

The <View nativeID="clerk-captcha" /> element is required in sign-up forms. It uses nativeID (not id) in React Native. Cloudflare-based bot detection has limitations in non-browser environments, but this element must be present.

setActive Callback Changes

The beforeEmit callback is replaced by navigate. The new callback receives session and decorateUrl:

Before (Core 2):

utils/auth-helpers.tsx
await setActive({
  session: result.createdSessionId,
  beforeEmit: (session) => {
    router.push('/(home)')
  },
})

After (Core 3, @clerk/expo >=3.0.0):

utils/auth-helpers.tsx
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):

utils/token-helper.tsx
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):

utils/token-helper.tsx
import { useAuth } from '@clerk/expo'

async function useApiToken() {
  const { getToken } = useAuth()
  const token = await getToken() // always a function, use try/catch for errors
}

Tip

The signIn and signUp objects from the new hooks are not referentially stable. They change identity as the flow progresses. Always include them in useEffect, useCallback, and useMemo dependency arrays.

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.

app/(auth)/sign-in-legacy.tsx
// 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):

app/_layout.tsx
<ClerkProvider
  appearance={{
    layout: {
      showOptionalFields: true,
    },
  }}
></ClerkProvider>

After (Core 3, @clerk/expo >=3.0.0):

app/_layout.tsx
<ClerkProvider
  appearance={{
    options: {
      showOptionalFields: false,
    },
  }}
></ClerkProvider>

Other appearance changes:

  • showOptionalFields default changed from true to false. Set it explicitly if you want optional fields visible.
  • colorRing and colorModalBackdrop now render at full opacity. Use rgba() values to restore previous behavior.
  • Experimental prefixes standardized. All experimental_ and experimental__ 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):

app/_layout.tsx
<ClerkProvider afterSignInUrl="/(home)" afterSignUpUrl="/(home)"></ClerkProvider>

After (Core 3, @clerk/expo >=3.0.0):

app/_layout.tsx
<ClerkProvider
  signInFallbackRedirectUrl="/(home)"
  signUpFallbackRedirectUrl="/(home)"
></ClerkProvider>
BeforeAfter
afterSignInUrlsignInFallbackRedirectUrl
afterSignUpUrlsignUpFallbackRedirectUrl
redirectUrlsignInFallbackRedirectUrl
For forced redirectssignInForceRedirectUrl / signUpForceRedirectUrl

SAML to Enterprise SSO

SAML references are renamed to enterprise SSO throughout the API:

Before (Core 2):

utils/enterprise-auth.tsx
// Core 2 SAML references
const samlAccounts = user.samlAccounts
await signIn.create({ strategy: 'saml', identifier: email })

After (Core 3, @clerk/expo >=3.0.0):

utils/enterprise-auth.tsx
// Core 3 enterprise SSO references
const enterpriseAccounts = user.enterpriseAccounts

In Expo, use useSSO() with the renamed strategy for enterprise SSO flows:

components/EnterpriseSSOButton.tsx
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):

components/OAuthButton.tsx
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):

components/SSOButton.tsx
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

BeforeAfter
client.activeSessionsclient.sessions
ClerkAPIError.kind === 'ClerkApiError'ClerkAPIError.kind === 'ClerkAPIError'
verification.samlAccountverification.enterpriseAccount
userSettings.samluserSettings.enterpriseSSO
import { Clerk }Use useClerk() or getClerkInstance()

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).

Note

Native components are in beta. They require a development build and Expo SDK 53+.

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.

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'

export default function SignInScreen() {
  const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false })
  const router = useRouter()

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

  return <AuthView mode="signInOrUp" />
}

Props:

PropTypeDefaultDescription
mode'signIn' | 'signUp' | 'signInOrUp''signInOrUp'Controls which flow to display
isDismissablebooleanfalseShows/hides a dismiss button

Warning

Don't set isDismissable={true} inside a React Native <Modal>. This creates conflicting dismiss behaviors.

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.

app/(home)/_layout.tsx
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:

app/(home)/profile.tsx
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:

  1. Native auth completes and creates a session
  2. Bearer token syncs to the native SDK
  3. JS SDK picks up the session
  4. 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-crypto

Configure 3 OAuth client IDs in the Google Cloud Console (iOS, Android, Web) and set them as environment variables (Google Sign-In guide).

components/GoogleSignIn.tsx
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-crypto

Register in the Clerk Dashboard with your Team ID + Bundle ID (Apple Sign-In guide).

components/AppleSignIn.tsx
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-store

Properties: hasCredentials, userOwnsCredentials, biometricType ('face-recognition' | 'fingerprint' | null). Methods: setCredentials(), clearCredentials(), authenticate().

Workflow:

  1. User signs in with password
  2. Call setCredentials() to store credentials
  3. On future launches, call authenticate() for biometric sign-in
app/(auth)/sign-in.tsx
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>
  )
}

Note

Local credentials only work for password-based sign-in on native platforms (not web). See the Local Credentials guide.

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 prebuild

Enable passkeys in your Clerk Dashboard's authentication settings. Then configure ClerkProvider:

app/_layout.tsx
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>
  )
}

Warning

@clerk/expo-passkeys has a peer dependency of expo >=53 <55, which is narrower than @clerk/expo's range of >=53 <56. If you're on Expo SDK 55, check for an updated version of @clerk/expo-passkeys before installing.

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/expo config 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:
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:
app.json
{
  "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:

components/CreatePasskey.tsx
import { useUser } from '@clerk/expo'

function CreatePasskeyButton() {
  const { user } = useUser()

  const onCreate = async () => {
    await user.createPasskey()
  }

  // render button...
}

Sign in with a passkey:

components/PasskeySignIn.tsx
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):

utils/api.tsx
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):

utils/api.tsx
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.

Tip

ClerkOfflineError.is() is for getToken() calls specifically. For custom sign-in/sign-up flows, use isClerkRuntimeError from @clerk/expo with the network_error code instead:

import { isClerkRuntimeError } from '@clerk/expo'

try {
  await signIn.password({ emailAddress: email, password })
} catch (err) {
  if (isClerkRuntimeError(err) && err.code === 'network_error') {
    // Handle offline scenario in custom flows
  }
}

See the Offline Support guide for details.

Experimental Offline Support

For full offline resilience, pass resourceCache to ClerkProvider. It caches authentication state, environment data, and session JWTs using expo-secure-store.

app/_layout.tsx
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:

app/(home)/_layout.tsx
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:

app/(auth)/_layout.tsx
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:

app/(home)/admin/_layout.tsx
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>
  )
}

Tip

useAuth() and useUser() work with any navigation library (React Navigation, etc.), not only Expo Router. The auth state hooks are navigation-agnostic.

Organizations and Multi-Tenant Authorization

Organizations in Core 3 use the same <Show> component for multi-tenant authorization checks.

Organization Authorization Patterns

app/(home)/dashboard.tsx
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/upgrade CLI
  • Package renamed from @clerk/clerk-expo to @clerk/expo
  • All import paths updated (see import reference table in Step 2)
  • publishableKey explicitly passed to ClerkProvider
  • tokenCache from @clerk/expo/token-cache configured
  • app.json plugins updated (@clerk/expo, expo-secure-store)
  • SignedIn/SignedOut/Protect replaced with <Show>
  • Hook API calls updated to Core 3 authentication API
  • Environment variables updated to EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY
  • Redirect props renamed (afterSignInUrl to signInFallbackRedirectUrl)
  • useOAuth replaced with useSSO if applicable
  • @clerk/types imports moved to @clerk/shared/types

Testing Authentication Flows

FlowWhat to Test
Email/password sign-insignIn.password() completes, signIn.finalize() navigates correctly
Email/password sign-upsignUp.password(), email verification, signUp.finalize()
OAuth (native)Google and Apple native flows on device
OAuth (browser)useSSO flows with browser redirect
MFAneeds_second_factor status, signIn.mfa.verifyEmailCode()
Client Trustneeds_client_trust on new device with password
Sign-outSession cleanup, UI updates

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: false is set on useAuth() and <Show>
  • Session sync completes within ~3 seconds of native auth

Testing Offline and Error Handling

  • Disable network, verify ClerkOfflineError is caught (not null)
  • Test biometric auth if using useLocalCredentials
  • Test passkeys on physical devices (not emulators)

Development vs. Production

EnvironmentHow to Test
Development buildnpx expo run:ios / npx expo run:android
Production-likeEAS Build
API keysSwitch from pk_test_ to pk_live_ (Production deployment)
Native featuresVerify in production builds via EAS

Troubleshooting Common Migration Issues

Breaking Changes Quick Reference

ChangeBefore (Core 2)After (Core 3)
Package name@clerk/clerk-expo@clerk/expo
Control componentsSignedIn / SignedOut / ProtectShow
Sign-in APIsignIn.create() + setActive()signIn.password() + signIn.finalize()
Sign-up APIsignUp.create() + setActive()signUp.password() + signUp.finalize()
Environment variableCLERK_FRONTEND_APIEXPO_PUBLIC_CLERK_PUBLISHABLE_KEY
Token offline behaviorReturns nullThrows ClerkOfflineError
Expo SDK minimum50.0.0+53.0.0+
Node.js minimum18.17.0+20.9.0+
OAuth hooksuseOAuth()useSSO()
Native OAuth imports@clerk/clerk-expo@clerk/expo/apple, @clerk/expo/google
Appearance configappearance.layoutappearance.options
Redirect propsafterSignInUrlsignInFallbackRedirectUrl
SAML strategystrategy: 'saml'strategy: 'enterprise_sso'
Error kind'ClerkApiError''ClerkAPIError'
Active sessionsclient.activeSessionsclient.sessions
Clerk exportimport { Clerk }useClerk() / getClerkInstance()
setActive callbackbeforeEmitnavigate
Passkey sign-insignIn.authenticateWithPasskey()signIn.passkey()

Common Errors and Fixes

ErrorCauseFix
Cannot find module @clerk/clerk-expoPackage not renamednpx expo install @clerk/expo
publishableKey is requiredNot passed explicitlyAdd EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY to .env, pass to ClerkProvider
Native components don't renderUsing Expo GoRun npx expo run:ios or npx expo run:android
Tokens lost on restartexpo-secure-store missingnpx expo install expo-secure-store, add tokenCache
OAuth failsNative API not enabledEnable at Dashboard's Native Applications page
Passkeys fail on emulatorNot supportedUse a physical device
ClerkOfflineError not caughtUsing null-check patternSwitch to try/catch with ClerkOfflineError.is(error)
App crashes in productionpublishableKey missingEnv vars aren't inlined in RN builds; pass explicitly

Frequently Asked Questions