# Migrating from @clerk/clerk-expo to @clerk/expo

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](https://clerk.com/glossary.md#passkeys) 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](https://clerk.com/changelog/2026-03-03-core-3.md)). Run the Clerk Upgrade CLI to automate most import path changes, then follow this guide for the remaining breaking changes — including the new `Show` component, redesigned hooks, and native component adoption.

Core 2 is in long-term support until January 2027 ([Versioning docs](https://clerk.com/docs/guides/development/upgrading/versioning.md)). 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.

## Prerequisites and Compatibility Requirements

### Minimum Version Requirements

| Dependency          | Minimum Version  | Notes                                    |
| ------------------- | ---------------- | ---------------------------------------- |
| Expo SDK            | 53               | Peer dep `>=53 <56`                      |
| React Native        | 0.73.0           |                                          |
| React               | 18.0.0 or 19.0.0 | Peer dep `^18.0.0 || ^19.0.0`          |
| Node.js             | 20.9.0           |                                          |
| `@clerk/expo`       | 3.0.0            | Latest: 3.1.6 (April 2026)               |
| iOS (passkeys only) | 16.0             | Set manually via `expo-build-properties` |

If you're on an older Expo SDK, upgrade first. Follow the [Expo SDK upgrade walkthrough](https://docs.expo.dev/workflow/upgrading-expo-sdk-walkthrough/) to reach SDK 53+.

### Three Authentication Approaches

`@clerk/expo` supports 3 approaches. Choose based on your requirements:

| Approach                 | Auth UI                             | OAuth                    |   Requires Dev Build  | Best For                           |
| ------------------------ | ----------------------------------- | ------------------------ | :-------------------: | ---------------------------------- |
| JavaScript only          | Custom React Native flows           | Browser-based (`useSSO`) | No (works in Expo Go) | Full UI control                    |
| JS + Native Sign-in      | Custom flows + native OAuth buttons | Native (no browser)      |          Yes          | Custom UI with native Google/Apple |
| Native Components (beta) | Pre-built native UI (`AuthView`)    | Native (no browser)      |          Yes          | Fastest integration                |

### Development Build Requirement

Native features (`AuthView`, `UserButton`, native OAuth, passkeys) require a [development build](https://expo.dev/blog/expo-go-vs-development-builds). Expo Go can't load custom native code.

Create a development build:

```bash
npx expo run:ios
```

Or for Android:

```bash
npx expo run:android
```

For CI/CD, use [EAS Build](https://docs.expo.dev/build/introduction/).

### Clerk Dashboard Configuration

Before migrating, configure your Clerk Dashboard:

1. **Enable Native API** on the [Native Applications](https://dashboard.clerk.com/~/native-applications) page ([deployment guide](https://clerk.com/docs/guides/development/deployment/expo.md))
2. **Register your apps:** iOS (Team ID + Bundle ID), Android (package name)
3. [**Configure OAuth credentials**](https://dashboard.clerk.com/~/user-authentication/sso-connections) for Google and Apple sign-in if using native OAuth
4. [**Set up domains**](https://dashboard.clerk.com/~/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.

```bash
npx @clerk/upgrade
```

Other package managers:

```bash
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

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

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

For native components, add development dependencies:

```bash
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:

| Feature            | Old Import                                               | New Import                      |
| ------------------ | -------------------------------------------------------- | ------------------------------- |
| Core hooks         | `@clerk/clerk-expo`                                      | `@clerk/expo`                   |
| Control components | `@clerk/clerk-expo` (`SignedIn`, `SignedOut`, `Protect`) | `@clerk/expo` (`Show`)          |
| Native components  | N/A (new)                                                | `@clerk/expo/native`            |
| Token cache        | Custom implementation                                    | `@clerk/expo/token-cache`       |
| Resource cache     | N/A (new)                                                | `@clerk/expo/resource-cache`    |
| Passkeys           | N/A (new)                                                | `@clerk/expo/passkeys`          |
| Error types        | N/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 hooks       | N/A                                                      | `@clerk/expo/legacy`            |
| Types              | `@clerk/types`                                           | `@clerk/shared/types`           |

Before (Core 2):

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

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

filename: app/example.tsx
```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`](https://clerk.com/docs/reference/components/clerk-provider.md).

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](https://clerk.com/glossary/frontend-api.md) URL in base64 ([How Clerk Works](https://clerk.com/docs/guides/how-clerk-works/overview.md)).

Before (Core 2):

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

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

```bash
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.

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

Plugin options:

| Option            | Type      | Default     | Description                            |
| ----------------- | --------- | ----------- | -------------------------------------- |
| `appleSignIn`     | `boolean` | `true`      | Adds Apple Sign-In entitlement         |
| `keychainService` | `string`  | `undefined` | For 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.

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

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

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

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

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

| Pattern       | Example                                        | Core 2 Equivalent                    |
| ------------- | ---------------------------------------------- | ------------------------------------ |
| Signed in     | `when="signed-in"`                             | `<SignedIn>`                         |
| Signed out    | `when="signed-out"`                            | `<SignedOut>`                        |
| Role          | `when={{ role: 'org:admin' }}`                 | `<Protect role="org:admin">`         |
| Permission    | `when={{ permission: 'org:invoices:create' }}` | `<Protect permission="...">`         |
| Feature (new) | `when={{ feature: 'premium_access' }}`         | N/A                                  |
| Plan (new)    | `when={{ plan: 'bronze' }}`                    | N/A                                  |
| Custom logic  | `when={(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:

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

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

filename: app/(auth)/sign-in.tsx
```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](https://clerk.com/glossary/credential-stuffing.md) 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](https://clerk.com/changelog/2025-11-14-client-trust-credential-stuffing-killer.md)). Only affects password-based sign-ins.

### useSignUp: Before and After

Before (Core 2):

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

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

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

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

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

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

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

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

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

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

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

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

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

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

filename: app/\_layout.tsx
```tsx
<ClerkProvider afterSignInUrl="/(home)" afterSignUpUrl="/(home)"></ClerkProvider>
```

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

filename: app/\_layout.tsx
```tsx
<ClerkProvider
  signInFallbackRedirectUrl="/(home)"
  signUpFallbackRedirectUrl="/(home)"
></ClerkProvider>
```

| Before               | After                                               |
| -------------------- | --------------------------------------------------- |
| `afterSignInUrl`     | `signInFallbackRedirectUrl`                         |
| `afterSignUpUrl`     | `signUpFallbackRedirectUrl`                         |
| `redirectUrl`        | `signInFallbackRedirectUrl`                         |
| For forced redirects | `signInForceRedirectUrl` / `signUpForceRedirectUrl` |

### SAML to Enterprise SSO

SAML references are renamed to enterprise [SSO](https://clerk.com/glossary/single-sign-on-sso.md) throughout the API:

Before (Core 2):

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

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

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

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

filename: components/EnterpriseSSOButton.tsx
```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](https://clerk.com/glossary/single-sign-on-sso.md) and OAuth flows:

Before (Core 2):

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

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

| Before                                   | After                                    |
| ---------------------------------------- | ---------------------------------------- |
| `client.activeSessions`                  | `client.sessions`                        |
| `ClerkAPIError.kind === 'ClerkApiError'` | `ClerkAPIError.kind === 'ClerkAPIError'` |
| `verification.samlAccount`               | `verification.enterpriseAccount`         |
| `userSettings.saml`                      | `userSettings.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](https://clerk.com/changelog/2026-03-09-expo-native-components.md)).

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

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

| Prop            | Type                                   | Default        | Description                    |
| --------------- | -------------------------------------- | -------------- | ------------------------------ |
| `mode`          | `'signIn' | 'signUp' | 'signInOrUp'` | `'signInOrUp'` | Controls which flow to display |
| `isDismissable` | `boolean`                              | `false`        | Shows/hides a dismiss button   |

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

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

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

```bash
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](https://clerk.com/docs/expo/guides/configure/auth-strategies/sign-in-with-google.md)).

filename: components/GoogleSignIn.tsx
```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:

```bash
npx expo install expo-apple-authentication expo-crypto
```

Register in the Clerk Dashboard with your Team ID + Bundle ID ([Apple Sign-In guide](https://clerk.com/docs/expo/guides/configure/auth-strategies/sign-in-with-apple.md)).

filename: components/AppleSignIn.tsx
```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:

```bash
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

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

> Local credentials only work for password-based sign-in on native platforms (not web). See the [Local Credentials guide](https://clerk.com/docs/guides/development/local-credentials.md).

## Step 10: Passkeys Configuration

Passkeys provide passwordless authentication using WebAuthn. This feature is experimental in `@clerk/expo`.

### Installation

```bash
npx expo install @clerk/expo-passkeys expo-build-properties
npx expo prebuild
```

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

filename: app/\_layout.tsx
```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>
  )
}
```

> `@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`:

filename: app.json
```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:

filename: app.json
```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:

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

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

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

  // render button...
}
```

Sign in with a passkey:

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

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

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

> `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:
>
> ```tsx
> 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](https://clerk.com/docs/guides/development/offline-support.md) 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`.

filename: app/\_layout.tsx
```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](https://clerk.com/changelog/2024-12-12-expo-offline-support.md)).

### 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](https://clerk.com/docs/guides/how-clerk-works/overview.md)). 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:

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

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

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

> `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](https://clerk.com/glossary/multi-tenancy.md) authorization checks.

### Organization Authorization Patterns

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

| Flow                   | What to Test                                                           |
| ---------------------- | ---------------------------------------------------------------------- |
| Email/password sign-in | `signIn.password()` completes, `signIn.finalize()` navigates correctly |
| Email/password sign-up | `signUp.password()`, email verification, `signUp.finalize()`           |
| OAuth (native)         | Google and Apple native flows on device                                |
| OAuth (browser)        | `useSSO` flows with browser redirect                                   |
| MFA                    | `needs_second_factor` status, `signIn.mfa.verifyEmailCode()`           |
| Client Trust           | `needs_client_trust` on new device with password                       |
| Sign-out               | Session 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

| Environment       | How to Test                                                                                                                        |
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| Development build | `npx expo run:ios` / `npx expo run:android`                                                                                        |
| Production-like   | EAS Build                                                                                                                          |
| API keys          | Switch from `pk_test_` to `pk_live_` ([Production deployment](https://clerk.com/docs/guides/development/deployment/production.md)) |
| Native features   | Verify in production builds via EAS                                                                                                |

## Troubleshooting Common Migration Issues

### Breaking Changes Quick Reference

| Change                 | Before (Core 2)                      | After (Core 3)                            |
| ---------------------- | ------------------------------------ | ----------------------------------------- |
| Package name           | `@clerk/clerk-expo`                  | `@clerk/expo`                             |
| Control components     | `SignedIn` / `SignedOut` / `Protect` | `Show`                                    |
| Sign-in API            | `signIn.create()` + `setActive()`    | `signIn.password()` + `signIn.finalize()` |
| Sign-up API            | `signUp.create()` + `setActive()`    | `signUp.password()` + `signUp.finalize()` |
| Environment variable   | `CLERK_FRONTEND_API`                 | `EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY`       |
| Token offline behavior | Returns `null`                       | Throws `ClerkOfflineError`                |
| Expo SDK minimum       | 50.0.0+                              | 53.0.0+                                   |
| Node.js minimum        | 18.17.0+                             | 20.9.0+                                   |
| OAuth hooks            | `useOAuth()`                         | `useSSO()`                                |
| Native OAuth imports   | `@clerk/clerk-expo`                  | `@clerk/expo/apple`, `@clerk/expo/google` |
| Appearance config      | `appearance.layout`                  | `appearance.options`                      |
| Redirect props         | `afterSignInUrl`                     | `signInFallbackRedirectUrl`               |
| SAML strategy          | `strategy: 'saml'`                   | `strategy: 'enterprise_sso'`              |
| Error kind             | `'ClerkApiError'`                    | `'ClerkAPIError'`                         |
| Active sessions        | `client.activeSessions`              | `client.sessions`                         |
| Clerk export           | `import { Clerk }`                   | `useClerk()` / `getClerkInstance()`       |
| setActive callback     | `beforeEmit`                         | `navigate`                                |
| Passkey sign-in        | `signIn.authenticateWithPasskey()`   | `signIn.passkey()`                        |

### Common Errors and Fixes

| Error                                  | Cause                       | Fix                                                                        |
| -------------------------------------- | --------------------------- | -------------------------------------------------------------------------- |
| `Cannot find module @clerk/clerk-expo` | Package not renamed         | `npx expo install @clerk/expo`                                             |
| `publishableKey is required`           | Not passed explicitly       | Add `EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY` to `.env`, pass to `ClerkProvider` |
| Native components don't render         | Using Expo Go               | Run `npx expo run:ios` or `npx expo run:android`                           |
| Tokens lost on restart                 | `expo-secure-store` missing | `npx expo install expo-secure-store`, add `tokenCache`                     |
| OAuth fails                            | Native API not enabled      | Enable at Dashboard's Native Applications page                             |
| Passkeys fail on emulator              | Not supported               | Use a physical device                                                      |
| `ClerkOfflineError` not caught         | Using null-check pattern    | Switch to try/catch with `ClerkOfflineError.is(error)`                     |
| App crashes in production              | `publishableKey` missing    | Env vars aren't inlined in RN builds; pass explicitly                      |

## Frequently Asked Questions

## FAQ

### Is @clerk/clerk-expo still maintained?

No. `@clerk/clerk-expo` is deprecated. Core 2 receives long-term support until January 2027 ([Versioning docs](https://clerk.com/docs/guides/development/upgrading/versioning.md)), but no new features will be added. Migrate to `@clerk/expo` for continued support, security updates, and access to native components, passkeys, and the Core 3 authentication hook API.

### Can I use @clerk/expo with Expo Go?

Partially. Core hooks (`useAuth`, `useUser`, `useSignIn`, `useSignUp`) and the `<Show>` component work in Expo Go. Native components (AuthView, UserButton), native OAuth (`useSignInWithGoogle`, `useSignInWithApple`), and passkeys require a [development build](https://expo.dev/blog/expo-go-vs-development-builds).

### What is the minimum Expo SDK version for @clerk/expo v3?

Expo SDK 53. The peer dependency range is `>=53 <56`. If you are on an older SDK, upgrade using the [Expo SDK upgrade walkthrough](https://docs.expo.dev/workflow/upgrading-expo-sdk-walkthrough/).

### Does the Clerk Upgrade CLI handle all migration changes?

It handles import paths, component replacements (`SignedIn`/`SignedOut`/`Protect` to `Show`), and package renames through AST-level transforms. It does not handle `app.json` plugin changes, environment variables, Core 3 authentication hook APIrefactoring for `useSignIn`/`useSignUp`, `useOAuth` to `useSSO`, or offline error handling.

### How do I handle offline authentication after migrating?

Use the experimental `resourceCache` from `@clerk/expo/resource-cache` and pass it to `ClerkProvider`. Handle `ClerkOfflineError` with `ClerkOfflineError.is(error)` from `@clerk/react/errors` instead of checking for null tokens. See [Offline Support](https://clerk.com/docs/guides/development/offline-support.md).

### What replaced Clerk Elements for custom UI?

Clerk Elements is deprecated in Core 3. The redesigned `useSignIn` and `useSignUp` hooks with the Core 3 authentication hook API provide a simpler pattern for custom authentication UI. Methods like `signIn.password()`, `signIn.emailCode.sendCode()`, and `signIn.finalize()` replace the old imperative flow.

### Do I need to update my Clerk Dashboard settings?

Yes. Enable "Native API" on the Native Applications page, register your iOS app (Team ID + Bundle ID) and Android app (package name), and configure OAuth credentials. See the [deployment guide](https://clerk.com/docs/guides/development/deployment/expo.md).

### What replaces the Protect component for authorization?

The `<Show>` component with the `when` prop. It supports roles (`when={{ role: 'org:admin' }}`), permissions (`when={{ permission: 'org:invoices:create' }}`), features, plans, and custom callback functions. See the [Show component reference](https://clerk.com/docs/reference/components/control/show.md).

### How do I set up native Google or Apple Sign-In?

Use `useSignInWithGoogle` from `@clerk/expo/google` or `useSignInWithApple` from `@clerk/expo/apple`. These use platform-native APIs (ASAuthorization on iOS, Credential Manager on Android) without browser redirects. Or use `<AuthView>` which handles social sign-in automatically.

### Is passkey support stable in @clerk/expo?

Passkeys are experimental (used via the `__experimental_passkeys` prop). They require iOS 16+ or Android 9+ on physical devices. Emulators do not support passkeys. See [Passkeys configuration](https://clerk.com/docs/reference/expo/passkeys.md).
