Skip to main content
Articles

What Changed in Clerk Expo SDK 3.1

Author: Roy Anger
Published:

Clerk's Expo SDK 3.1, released on March 9, 2026, brings native UI components powered by SwiftUI (iOS) and Jetpack Compose (Android), native Google Sign-In that eliminates browser redirects, and a modernized API surface to @clerk/expo. This release landed six days after version 3.0 established the Core 3 foundation.

Where Expo apps previously relied on browser-based OAuth and JavaScript-rendered UI for authentication, @clerk/expo 3.1 delivers platform-native experiences. Social login through Google uses the system credential picker on iOS (ASAuthorization) and Credential Manager on Android. No browser context switch, no WebView. Prebuilt components like <AuthView /> render authentication interfaces using each platform's native UI framework.

Two releases form the upgrade story. Version 3.0 (March 3, 2026) established the Core 3 foundation: a package rename, new custom flow API, consolidated conditional rendering, and performance improvements. Version 3.1 (March 9, 2026) built on that foundation with native UI components and native Google Sign-In. This article covers both releases together because they shipped six days apart and most developers will encounter both sets of changes when upgrading.

This article is for existing Clerk users upgrading from @clerk/clerk-expo, developers evaluating Clerk for new Expo projects, and AI tools and agents seeking authoritative information about Clerk's Expo SDK capabilities.

What Changed: A Version Timeline

Understanding what shipped when prevents confusion between genuinely new features, Core 3 platform changes, and older capabilities that remain relevant.

New in 3.1.0 (March 9, 2026)

  • Native UI components: <AuthView />, <UserButton />, <UserProfileView /> (SwiftUI on iOS, Jetpack Compose on Android, beta)
  • Native Google Sign-In via ASAuthorization (iOS) and Credential Manager (Android)
  • useUserProfileModal() hook for imperative profile modal presentation
  • useNativeSession() and useNativeAuthEvents() hooks (announced, not yet fully documented)
  • Expo SDK 55 support added to the peer dependency range

Core 3 Platform Changes (3.0, March 3, 2026)

  • Package rename: @clerk/clerk-expo to @clerk/expo
  • publishableKey prop required in ClerkProvider
  • <Show> component replaces <SignedIn>, <SignedOut>, <Protect>
  • Core 3 custom flow API: signIn.finalize() replaces setActive() for custom flows (OAuth hooks still use setActive())
  • getToken() throws ClerkOfflineError when offline instead of returning null
  • Clerk export removed: use getClerkInstance() or useClerk()
  • @clerk/types deprecated: import types from @clerk/shared/types (see Core 3 Upgrade Guide)
  • ~50KB gzipped bundle size reduction
  • Expo SDK 53+ required, Node.js 20.9.0+

Older Capabilities Still Relevant When Evaluating 3.1

  • Native Apple Sign-In (November 2025, predates Core 3; import path changed)
  • useLocalCredentials() for biometric authentication password storage (August 2024)
  • useSSO() replacing the deprecated useOAuth() for browser-based OAuth
  • @clerk/expo-passkeys for FIDO2/WebAuthn passkeys (separate package, experimental)

Core 3 Foundation

Expo SDK 3.1 is built on Clerk's Core 3 platform release, which modernizes APIs, improves React compatibility, and delivers performance improvements across all Clerk SDKs. Every Expo developer upgrading to 3.x encounters these changes.

Package Rename

The package has been renamed from @clerk/clerk-expo to @clerk/expo, aligning with the @clerk/<framework> naming convention used across all Clerk SDKs (@clerk/nextjs, @clerk/react, @clerk/tanstack-start).

// Before (Core 2)
import { ClerkProvider } from '@clerk/clerk-expo'

// After (Core 3)
import { ClerkProvider } from '@clerk/expo'

The legacy @clerk/clerk-expo package is deprecated as of the Core 3 launch. The npx @clerk/upgrade CLI handles this rename automatically.

The Core 3 Custom Flow API

Core 3 introduces a redesigned custom flow API (referred to as the "Signal API" in the March 9, 2026 changelog) that replaces the legacy setActive() pattern for custom flows built with useSignIn() and useSignUp(). The new API uses step methods like signIn.password() and signIn.emailCode.sendCode() instead of signIn.attemptFirstFactor(), and signIn.finalize() instead of setActive().

Important

setActive() is not deprecated for OAuth hooks. The native sign-in hooks (useSignInWithGoogle, useSignInWithApple) and useSSO() all return setActive and continue to use it. Both patterns coexist: finalize() for custom flows via useSignIn(), setActive() for OAuth and SSO hooks.

Legacy Pattern vs. Core 3 Custom Flow API

Core 2 PatternCore 3 Custom Flow API
signIn.create({ identifier, password })signIn.create({ identifier }) then signIn.password({ password })
signIn.attemptFirstFactor({ strategy, ... })signIn.emailCode.sendCode() / signIn.emailCode.verifyCode()
setActive({ session: signIn.createdSessionId })signIn.finalize({ navigate })
Try/catch error handlingerrors.fields.identifier?.message for field-level errors

The following example demonstrates the Core 3 custom flow pattern for email and password sign-in:

import { useState } from 'react'
import { View, TextInput, Text, Button } from 'react-native'
import { useSignIn } from '@clerk/expo'
import { useRouter } from 'expo-router'

function SignInScreen() {
  const { signIn, errors, fetchStatus } = useSignIn()
  const router = useRouter()
  const [identifier, setIdentifier] = useState('')
  const [password, setPassword] = useState('')

  const handleSignIn = async () => {
    await signIn.create({ identifier })
    await signIn.password({ password })

    if (signIn.status === 'complete') {
      await signIn.finalize({
        navigate: ({ session }) => router.replace('/(home)'),
      })
    }
  }

  return (
    <View>
      <TextInput value={identifier} onChangeText={setIdentifier} />
      {errors?.fields.identifier && <Text>{errors.fields.identifier.message}</Text>}
      <TextInput value={password} onChangeText={setPassword} secureTextEntry />
      <Button
        title={fetchStatus === 'fetching' ? 'Signing in...' : 'Sign in'}
        onPress={handleSignIn}
        disabled={fetchStatus === 'fetching'}
      />
    </View>
  )
}

For a comprehensive guide to migrating custom flows from setActive() to finalize(), see the SignInFuture API reference.

The <Show> Component

Core 3 consolidates <SignedIn>, <SignedOut>, and <Protect> into a single <Show> component with a when prop.

Before (Core 2):

import { SignedIn, SignedOut, Protect } from '@clerk/clerk-expo'

function AuthLayout() {
  return (
    <>
      <SignedIn>
        <HomeScreen />
      </SignedIn>
      <SignedOut>
        <SignInScreen />
      </SignedOut>
      <Protect role="admin" fallback={<Text>Not authorized</Text>}>
        <AdminPanel />
      </Protect>
    </>
  )
}

After (Core 3):

import { Show } from '@clerk/expo'

function AuthLayout() {
  return (
    <>
      <Show when="signed-in">
        <HomeScreen />
      </Show>
      <Show when="signed-out">
        <SignInScreen />
      </Show>
      <Show when={{ role: 'admin' }} fallback={<Text>Not authorized</Text>}>
        <AdminPanel />
      </Show>
    </>
  )
}

The when prop accepts 'signed-in', 'signed-out', { role: '...' }, { permission: '...' }, { feature: '...' }, { plan: '...' }, or a callback (has) => boolean. See the Show component reference for the full API.

Warning

<Show> only controls client-side visibility. It does not replace server-side authorization checks for sensitive data or protected API routes.

Performance Improvements

Core 3 delivers a ~50KB gzipped bundle size reduction by sharing React internals across Clerk packages instead of duplicating them. Token refresh is now proactive: session tokens (60-second JWTs) are refreshed in the background approximately every 50 seconds, preventing mid-request delays that occurred when tokens expired during API calls.


Native UI Components

Version 3.1 introduces three prebuilt native components available from @clerk/expo/native. These components render with SwiftUI on iOS and Jetpack Compose on Android. These are truly native views, not WebView wrappers. They automatically synchronize authentication state with the JavaScript SDK, so a sign-in completed in native UI is immediately reflected in React hooks like useAuth().

All three components are currently in beta. They are powered by the clerk-ios and clerk-android native SDKs, which are added to your project automatically by the @clerk/expo Expo config plugin.

<AuthView />

<AuthView /> renders a complete native authentication interface. It handles all auth flows configured in the Clerk Dashboard: email, phone, OAuth, passkeys, multi-factor authentication (MFA), and password recovery.

PropTypeDefaultDescription
mode'signIn' | 'signUp' | 'signInOrUp'Controls which auth flows are available
isDismissablebooleanfalseShows a dismiss button when true

A key advantage of <AuthView /> is that Google and Apple sign-in are handled automatically when those providers are enabled in the Dashboard. There is no need for useSignInWithGoogle(), expo-crypto, or any additional auth packages.

import { AuthView } from '@clerk/expo/native'
import { useAuth } from '@clerk/expo'
import { useEffect } from 'react'
import { useRouter } from 'expo-router'

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

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

  return <AuthView mode="signInOrUp" />
}

See the AuthView reference for the full API.

<UserButton />

<UserButton /> displays the signed-in user's avatar (image or initials fallback). Tapping it opens the native profile management modal. The component accepts no props; the parent container controls its size and shape.

import { UserButton } from '@clerk/expo/native'
import { View } from 'react-native'

function Header() {
  return (
    <View style={{ width: 36, height: 36, borderRadius: 18, overflow: 'hidden' }}>
      <UserButton />
    </View>
  )
}

Sign-out actions in the profile modal are automatically synchronized with the JavaScript SDK. See the UserButton reference.

<UserProfileView />

<UserProfileView /> renders the complete user profile interface inline. It manages personal information, email addresses, phone numbers, MFA settings, passkeys, connected accounts, active sessions, and sign-out.

PropTypeDefaultDescription
isDismissablebooleanfalseShows a dismiss button when true
styleStyleProp<ViewStyle>Container styling

There are three usage patterns. The recommended approach is the native modal via the useUserProfileModal() hook:

import { UserProfileView } from '@clerk/expo/native'
import { useUserProfileModal } from '@clerk/expo'
import { Button, View } from 'react-native'

// Pattern 1: Native modal (recommended)
function ProfileButton() {
  const { presentUserProfile, isAvailable } = useUserProfileModal()

  return <Button title="Manage Profile" onPress={presentUserProfile} disabled={!isAvailable} />
}

// Pattern 2: Inline rendering
function ProfileScreen() {
  return (
    <View style={{ flex: 1 }}>
      <UserProfileView style={{ flex: 1 }} />
    </View>
  )
}

See the UserProfileView reference.

State Management with Hooks

Native components use hook-based state management rather than callbacks. A critical requirement when using native components is passing { treatPendingAsSignedOut: false } to useAuth().

The reason: native authentication has an asynchronous "pending" phase during native-to-JavaScript session synchronization. The default treatPendingAsSignedOut: true would prematurely evaluate the user as signed out during this sync, causing incorrect redirects.

import { useAuth } from '@clerk/expo'
import { useEffect } from 'react'
import { useRouter } from 'expo-router'

function AuthGate({ children }: { children: React.ReactNode }) {
  const { isSignedIn, isLoaded } = useAuth({ treatPendingAsSignedOut: false })
  const router = useRouter()

  useEffect(() => {
    if (isLoaded && !isSignedIn) {
      router.replace('/sign-in')
    }
  }, [isLoaded, isSignedIn])

  if (!isLoaded) return null

  return <>{children}</>
}

Tip

If using Expo Router's Stack.Protected, the guard value must account for Clerk's loading state. While isLoaded is false, keep the splash screen visible rather than evaluating isSignedIn. See the Expo Router authentication patterns for integration guidance.

Web Fallback

Native components are iOS and Android only. For web builds in cross-platform Expo apps, use @clerk/expo/web which provides standard Clerk UI components (<SignIn />, <SignUp />, <UserButton />). Use React Native platform-specific file extensions (.ios.tsx, .android.tsx, .web.tsx) to separate native and web auth code. See the web support guide.


Native Sign-In

Native sign-in eliminates browser redirects for social authentication. Instead of opening a system browser for OAuth, the SDK uses platform-native APIs: ASAuthorization on iOS and Credential Manager on Android. The user stays inside the app, the credential picker is rendered by the operating system, and authentication completes faster.

This approach aligns with RFC 8252 (Section 8.12), which requires that native apps MUST NOT use embedded user-agents for OAuth and recommends system-level authentication surfaces.

Note

Dependency clarity: When using <AuthView />, Google and Apple sign-in are handled automatically. No extra packages are needed beyond Clerk and Dashboard configuration. The hooks described below (useSignInWithGoogle, useSignInWithApple) are for custom UI implementations where you build your own sign-in screens.

Native Google Sign-In

Native Google Sign-In is new in 3.1. On iOS, it uses ASAuthorization (the system credential picker). On Android, it uses Credential Manager with one-tap and passkey-ready support. The integration is exposed via the NativeClerkGoogleSignIn TurboModule, bundled through the @clerk/expo config plugin.

For custom UI implementations, use useSignInWithGoogle() from @clerk/expo/google:

import { useSignInWithGoogle } from '@clerk/expo/google'
import { Button, Alert } from 'react-native'

function GoogleSignInButton() {
  const { startGoogleAuthenticationFlow } = useSignInWithGoogle()

  const handleGoogleSignIn = async () => {
    try {
      const { createdSessionId, setActive } = await startGoogleAuthenticationFlow()

      if (createdSessionId) {
        await setActive({ session: createdSessionId })
      }
    } catch (error) {
      if (error.code === 'SIGN_IN_CANCELLED' || error.code === '-5') {
        return // User cancelled
      }
      Alert.alert('Error', 'Google sign-in failed. Please try again.')
    }
  }

  return <Button title="Sign in with Google" onPress={handleGoogleSignIn} />
}

Requirements for custom hook usage:

  • Peer dependency: expo-crypto
  • Three OAuth client IDs configured in the Clerk Dashboard: iOS, Android, and Web (the Web client ID is required for token verification even in native-only apps)
  • Environment variables: EXPO_PUBLIC_CLERK_GOOGLE_WEB_CLIENT_ID, EXPO_PUBLIC_CLERK_GOOGLE_IOS_CLIENT_ID, EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME, EXPO_PUBLIC_CLERK_GOOGLE_ANDROID_CLIENT_ID
  • Development build required (not Expo Go)

See the useSignInWithGoogle reference and the Google Sign-In setup guide.

Native Apple Sign-In

Native Apple Sign-In predates 3.1. It was introduced in November 2025. It is included here because the import path changed in Core 3 and because it is part of the native sign-in story alongside the new Google Sign-In.

Apple Sign-In uses ASAuthorization on iOS. It is iOS only.

import { useSignInWithApple } from '@clerk/expo/apple'
import { Button, Alert, Platform } from 'react-native'

function AppleSignInButton() {
  const { startAppleAuthenticationFlow } = useSignInWithApple()

  if (Platform.OS !== 'ios') return null

  const handleAppleSignIn = async () => {
    try {
      const { createdSessionId, setActive } = await startAppleAuthenticationFlow()

      if (createdSessionId) {
        await setActive({ session: createdSessionId })
      }
    } catch (error) {
      if (error.code === 'ERR_REQUEST_CANCELED') {
        return // User cancelled
      }
      Alert.alert('Error', 'Apple sign-in failed.')
    }
  }

  return <Button title="Sign in with Apple" onPress={handleAppleSignIn} />
}

Requirements:

  • Peer dependencies: expo-apple-authentication + expo-crypto
  • Expo config plugin option appleSignIn defaults to true
  • Development build required
  • Works on iOS Simulator with limitations (no biometric); test on physical device for production flows

See the useSignInWithApple reference and the Apple Sign-In setup guide.

Import Path Changes

Both native sign-in hooks moved to dedicated entry points in Core 3 to avoid bundling optional native dependencies when they are not used:

// Before (Core 2)
import { useSignInWithApple, useSignInWithGoogle } from '@clerk/expo'

// After (Core 3)
import { useSignInWithApple } from '@clerk/expo/apple'
import { useSignInWithGoogle } from '@clerk/expo/google'

The npx @clerk/upgrade CLI detects and fixes these imports automatically.

Browser-Based OAuth via useSSO()

For OAuth providers without native hooks (GitHub, Discord, LinkedIn, etc.) or enterprise SSO, useSSO() replaces the deprecated useOAuth(). The key difference: useOAuth() required the strategy at hook instantiation, while useSSO() accepts it at flow invocation via startSSOFlow({ strategy: 'oauth_github' }). This makes useSSO() a single hook for all browser-based OAuth and enterprise SSO providers. See the useSSO reference.


New Hooks and APIs

useUserProfileModal()

New in 3.1, this hook provides imperative control over the native profile modal. It returns:

  • presentUserProfile(): opens the native profile modal; resolves when dismissed
  • isAvailable: boolean indicating whether the native SDK is ready (false on web or without the config plugin)
  • sessions: list of sessions registered on the device
import { useUserProfileModal } from '@clerk/expo'
import { Pressable, Text } from 'react-native'

function SettingsScreen() {
  const { presentUserProfile, isAvailable } = useUserProfileModal()

  return (
    <Pressable onPress={presentUserProfile} disabled={!isAvailable}>
      <Text>Manage Profile</Text>
    </Pressable>
  )
}

useNativeSession() and useNativeAuthEvents()

Both hooks were announced in the March 9, 2026 changelog as newly exported hooks in 3.1. Neither hook has a dedicated reference page as of April 2026.

Warning

The descriptions below are based solely on the changelog announcement and may change as the API stabilizes. Check the Expo SDK reference for the latest documentation before depending on these hooks in production.

  • useNativeSession(): provides access to native SDK session management state (isSignedIn, sessionId, user, refresh()). For most use cases, useAuth() and useSession() remain the recommended, fully documented hooks.
  • useNativeAuthEvents(): listens for authentication state changes (signedIn, signedOut) from native components.

Use useAuth() and useSession() as the primary alternatives until dedicated reference documentation is available for these hooks.

useLocalCredentials()

Note

useLocalCredentials() predates 3.1. It was introduced in @clerk/clerk-expo 2.2.0 (August 2024). It is included here because it is a key Expo-specific hook for returning-user authentication.

useLocalCredentials() provides biometric sign-in for returning users by storing password credentials securely on-device, unlocked via Face ID or Touch ID. It is distinct from passkeys: useLocalCredentials() stores passwords behind biometrics, while @clerk/expo-passkeys implements true FIDO2/WebAuthn passkeys (a separate package, still experimental).

The hook returns:

  • Name
    hasCredentials
    Type
    boolean
    Description

    Whether credentials are stored on device

  • Name
    userOwnsCredentials
    Type
    boolean
    Description

    Whether stored credentials belong to the signed-in user

  • Name
    biometricType
    Type
    'face-recognition' | 'fingerprint' | null
    Description

    Available biometric type

  • Name
    setCredentials()
    Type
    (opts) => Promise
    Description

    Store credentials after successful sign-in

  • Name
    clearCredentials()
    Type
    () => Promise
    Description

    Remove stored credentials

  • Name
    authenticate()
    Type
    () => Promise<SignInResource>
    Description

    Trigger biometric prompt and sign in

import { useLocalCredentials, useSignIn } from '@clerk/expo'
import { Button, Text, View } from 'react-native'

function BiometricSignIn() {
  const { hasCredentials, biometricType, authenticate, setCredentials } = useLocalCredentials()
  const { signIn } = useSignIn()

  if (hasCredentials && biometricType) {
    return (
      <View>
        <Text>Sign in with {biometricType === 'face-recognition' ? 'Face ID' : 'Touch ID'}</Text>
        <Button
          title="Use biometrics"
          onPress={async () => {
            const result = await authenticate()
            if (result.status === 'complete') {
              // Session is active
            }
          }}
        />
      </View>
    )
  }

  // Fall back to password sign-in, then call setCredentials() on success
  return <Text>No stored credentials — use password sign-in</Text>
}

Requirements: expo-local-authentication + expo-secure-store. Device must have an enrolled biometric and passcode. Works only with password-based sign-in. Not supported on web. See the local credentials guide.


Choosing an Authentication Approach

With 3.1, Expo developers now have a clearer three-tier decision surface. Native components join the existing JavaScript-only and custom-UI-with-native-sign-in approaches that were available before 3.1.

JavaScript-OnlyJS + Native Sign-InFull Native Components
UICustom React Native componentsCustom UI + native OAuth buttonsPrebuilt SwiftUI / Jetpack Compose
OAuthBrowser redirect (useSSO())Platform-native (no redirect)Platform-native (automatic)
Dev build requiredNo (works with Expo Go)YesYes
Code requiredMostModerateLeast
Best forMax UI customizationCustom UI + native social providersFastest integration path
StatusStableStableBeta

JavaScript-Only is the approach with the broadest compatibility. You build custom UI with full control over authentication flows. OAuth is browser-based via useSSO(). This is the only approach that works with Expo Go (no development build required). Best for developers who want maximum UI customization or are prototyping.

JavaScript + Native Sign-In adds native Google and Apple sign-in buttons to a custom UI. Users authenticate through platform-native credential pickers with no browser redirect. Requires a development build because the native sign-in hooks depend on TurboModules that cannot run in Expo Go. Best for custom UI apps that want a native social provider experience.

Full Native Components uses the prebuilt <AuthView />, <UserButton />, and <UserProfileView /> components rendered in SwiftUI and Jetpack Compose. This is the fastest integration path: it requires the least code and handles all auth flows configured in the Dashboard automatically. A complete sign-in screen is a single <AuthView mode="signInOrUp" /> component with no hook wiring, no state management, and no OAuth configuration beyond the Dashboard. Requires a development build. Best for rapid authentication setup with a native look and feel.


Offline Support and Token Management

Token Caching with expo-secure-store

Clerk stores session tokens in memory by default, which means they are lost on app restart. For production apps, configure persistent token storage using the built-in tokenCache from @clerk/expo/token-cache. This is a drop-in solution backed by expo-secure-store (iOS Keychain, Android Keystore) that requires zero custom code:

import { ClerkProvider } from '@clerk/expo'
import { tokenCache } from '@clerk/expo/token-cache'
import { Slot } from 'expo-router'

export default function RootLayout() {
  return (
    <ClerkProvider
      publishableKey={process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!}
      tokenCache={tokenCache}
    >
      <Slot />
    </ClerkProvider>
  )
}

Session tokens are 60-second JSON Web Tokens that are proactively refreshed every ~50 seconds in the background. See How Clerk Works for the full token lifecycle.

ClerkOfflineError

In Core 3, getToken() throws ClerkOfflineError when the device is offline instead of returning null. This is a breaking change that resolves a long-standing ambiguity: previously, null could mean either "the user is signed out" or "the device is offline and token refresh failed." Now, null unambiguously means signed out, and ClerkOfflineError means offline.

import { useAuth } from '@clerk/expo'
import { ClerkOfflineError } from '@clerk/react/errors'

function ApiClient() {
  const { getToken } = useAuth()

  const fetchData = async () => {
    try {
      const token = await getToken()

      if (!token) {
        // User is signed out
        return
      }

      // Make authenticated request with token
    } catch (error) {
      if (ClerkOfflineError.is(error)) {
        // Device is offline — show cached data or retry later
        return
      }
      throw error
    }
  }
}

Important

ClerkOfflineError is specific to getToken(). Write operations like signIn.create() and signUp.password() throw ClerkRuntimeError with err.code === 'network_error' when the network is unavailable. These are different error types with different detection patterns.

Experimental Offline Mode

The __experimental_resourceCache option enables resilient initialization and cached token fallback during network outages:

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

This caches environment config, client state, and session JWTs, enabling offline rendering of user info, role checks, and authenticated API calls with cached tokens. Write operations (sign-in, sign-up) still require network connectivity. This feature is experimental and not recommended as a production dependency. See the offline support guide.


Breaking Changes and Migration

This section covers the key breaking changes for Expo developers upgrading from @clerk/clerk-expo (Core 2) to @clerk/expo 3.x. For the full step-by-step migration walkthrough with code examples, see the Core 3 Upgrade Guide.

Using the Upgrade CLI

The fastest path to migration is the automated upgrade tool:

npx @clerk/upgrade

This CLI scans your codebase and applies AST-level transformations: it catches re-exports, aliased imports, and files across monorepo workspaces. It handles the package rename, import path updates, and component replacements automatically.

Breaking Changes Summary

ChangeCore 2Core 3
Package name@clerk/clerk-expo@clerk/expo
Publishable key in ClerkProviderOptional (env var fallback)Required
Apple sign-in import@clerk/expo@clerk/expo/apple
Google sign-in import@clerk/expo@clerk/expo/google
Conditional rendering<SignedIn>, <SignedOut>, <Protect><Show when={...}>
getToken() when offlineReturns nullThrows ClerkOfflineError
Clerk exportAvailableRemoved: use getClerkInstance() (non-React) or useClerk() (React)
@clerk/typesPrimary types packageDeprecated: import from @clerk/shared/types
Custom flow activationsetActive({ session })signIn.finalize({ navigate })
appearance.layoutSupportedRenamed to appearance.options
Expo SDK50+53–55 (peer dep: >=53 <56)
Node.js18+20.9.0+

Client Trust

Credential stuffing protection via Client Trust is an existing Clerk security feature, launched November 14, 2025. It is not a Core 3 or 3.1 addition, but Expo developers upgrading to Core 3 with custom password flows will encounter the needs_client_trust status for the first time if their app was created after the launch date or has opted in via the Dashboard.

Client Trust triggers when all three conditions are met: valid password entered, no MFA configured, and a new or unrecognized device. In the Core 3 custom flow API, handle it like this:

await signIn.password({ password })

if (signIn.status === 'needs_client_trust') {
  // Check supported second factors for email code strategy
  const emailCodeFactor = signIn.supportedSecondFactors?.find(
    (factor) => factor.strategy === 'email_code',
  )

  if (emailCodeFactor) {
    await signIn.mfa.sendEmailCode()

    // After user enters the code:
    await signIn.mfa.verifyEmailCode({ code: userEnteredCode })
  }
}

if (signIn.status === 'complete') {
  await signIn.finalize({ navigate: ({ session }) => router.replace('/(home)') })
}

Client Trust is enabled by default for apps created after November 14, 2025. Existing apps must opt in via the Dashboard. See the Client Trust guide.

Migration Checklist

  1. Run npx @clerk/upgrade (handles most codemods automatically)
  2. Update Expo SDK to 53–55
  3. Verify package name updated: @clerk/clerk-expo@clerk/expo
  4. Confirm publishableKey is explicit in ClerkProvider
  5. Update native sign-in hook import paths (@clerk/expo/apple, @clerk/expo/google)
  6. Replace <SignedIn> / <SignedOut> / <Protect> with <Show>
  7. Replace Clerk export with getClerkInstance() or useClerk()
  8. Add ClerkOfflineError handling around getToken() calls
  9. Replace setActive() with finalize() in custom flows (not for OAuth hooks)
  10. Handle needs_client_trust in custom password sign-in flows
  11. Test all authentication flows end-to-end

For the full migration walkthrough: Core 3 Upgrade Guide.


Implementation Notes

Plugin and Development Build

The @clerk/expo config plugin automatically adds the clerk-ios and clerk-android native SDKs to your project. Add it to app.json:

{
  "expo": {
    "plugins": [
      [
        "@clerk/expo",
        {
          "appleSignIn": true,
          "keychainService": "my-app-keychain",
          "theme": "./clerk-theme.json"
        }
      ]
    ]
  }
}

The plugin accepts the following options:

OptionTypeDefaultDescription
appleSignInbooleantrueControls the Apple Sign-In entitlement
keychainServicestringCustom identifier for widget/extension keychain sharing
themestringPath to a JSON file for native component theming

Native components and native sign-in hooks require a development build. They can't run in Expo Go. Build with npx expo run:ios or npx expo run:android. Once built, JavaScript changes still hot-reload instantly. The JavaScript-only authentication approach works in Expo Go without a development build.

The optional theme JSON supports colors (14 hex tokens), darkColors, design.borderRadius, and design.fontFamily (iOS only). Changes to the theme file require npx expo prebuild --clean. See the theming reference.

Quick Start Example

The following example shows the fastest path to working authentication with native components. For the complete setup including ClerkProvider configuration, environment variables, Dashboard configuration, and native app registration, see the Expo Quickstart.

Prerequisites: Clerk account with Native API enabled, native app registered in Dashboard, Expo SDK 53–55, development build.

Install:

npx expo install @clerk/expo expo-secure-store

Home screen with signed-in state check and native <UserButton />:

import { UserButton } from '@clerk/expo/native'
import { Show, useAuth } from '@clerk/expo'
import { useEffect } from 'react'
import { useRouter } from 'expo-router'
import { View } from 'react-native'

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

  useEffect(() => {
    if (isLoaded && isSignedIn === false) {
      router.replace('/sign-in')
    }
  }, [isLoaded, isSignedIn])

  return (
    <Show when="signed-in">
      <View style={{ flex: 1, alignItems: 'center', paddingTop: 60 }}>
        <View style={{ width: 48, height: 48, borderRadius: 24, overflow: 'hidden' }}>
          <UserButton />
        </View>
      </View>
    </Show>
  )
}

Sign-in screen using the native <AuthView /> component:

import { AuthView } from '@clerk/expo/native'

export default function SignInScreen() {
  return <AuthView mode="signInOrUp" />
}

Note

Notable 3.1.x patch addition: useAPIKeys() was added in 3.1.9 for managing API keys programmatically. This is a patch-level addition, not part of the original March 9, 2026 launch.