Skip to main content
Articles

How to use SwiftUI components in a React Expo and Clerk app

Author: Roy Anger
Published:

Clerk's @clerk/expo package ships a /native subpath that exports three React components, AuthView, UserButton, and UserProfileView, which render as real SwiftUI views on iOS. On the JavaScript side you write one React Native component tree; on the device, UIHostingController embeds the native SwiftUI view inside React Native's Fabric hierarchy. Apple Sign-In uses the OS-level ASAuthorizationController credential sheet. Google Sign-In on iOS uses ASWebAuthenticationSession, the same RFC 8252 compliant Safari sheet that Google's own iOS SDK uses internally, because iOS does not expose a system-level Google credential picker.

On Android, the same <AuthView /> renders as Jetpack Compose, and Google Sign-In goes through the true native Credential Manager bottom sheet. One TypeScript file builds both platforms. This tutorial focuses on iOS and SwiftUI because that's where the bridging work is most visible; Android-specific setup is called out inline where it applies.

Note

Clerk's Expo native components are in public beta as of 2026-04-16. The API surface is stable enough to build with, but expect minor breaking changes before general availability. Pin @clerk/expo@^3.2.0 if you want a reproducible build.

What you'll build

By the end of this tutorial you'll have an Expo app that:

  • Renders <AuthView /> as a SwiftUI view with native Google Sign-In and native Apple Sign-In on iOS.
  • Displays a <UserButton /> in the Expo Router header that opens the native profile modal on tap.
  • Uses <UserProfileView /> for a dedicated profile screen (personal info, email, MFA, passkeys, connected accounts, active sessions, sign-out).
  • Gates the home stack behind Stack.Protected so only signed-in users can reach it.
  • Runs the same code on Android as Jetpack Compose, with Google Sign-In routed through the Android Credential Manager bottom sheet.

Testing the Google Sign-In flow works on the iOS Simulator. Testing Apple Sign-In reliably requires a physical iPhone. A full end-to-end iOS build takes about 30 minutes from a clean machine.

Why native authentication beats WebView OAuth

Native credential pickers close a specific attack surface that embedded WebView browsers cannot. Beyond the UX benefits over WebView-based authentication, that's the security reason standards bodies, Apple, and Google all moved away from WebView OAuth.

The WebView OAuth problem

Google announced the embedded-WebView OAuth block in August 2016 (Google Developers Blog, Aug 2016). New OAuth clients were blocked starting 2016-10-20; existing clients started receiving the disallowed_useragent error for WebView traffic on 2017-04-20. The reason is simple: a WebView is part of the host app's process, so the app can inject JavaScript, read the DOM, and capture keystrokes during the OAuth flow. RFC 8252 (BCP 212) codified this in October 2017: "this best current practice requires that native apps MUST NOT use embedded user-agents to perform authorization requests" (RFC 8252 Section 8.12). The 2025 OAuth 2.0 Security Best Current Practice (RFC 9700) reaffirmed the external-agent requirement (RFC 9700, Jan 2025).

Real-world evidence backs this up. Felix Krause demonstrated in August 2022 that Instagram and Facebook's in-app browsers inject tracking JavaScript into every third-party page, including sign-in forms (Krause, Aug 2022). That's ambient surveillance; a hostile host app could just as easily capture passwords.

The compliant-browser alternative

ASWebAuthenticationSession on iOS and Chrome Custom Tabs on Android are the standards-compliant alternatives. They run outside the host app's process, share cookies with Safari or Chrome (so users already signed into Google can continue with one tap), and prevent the app from observing keystrokes. This is the baseline every modern OAuth provider hits on iOS.

These sheets still show browser chrome and context-switch the user out of your app's visual flow. That's the UX trade-off: they're secure, but they're not borderless.

True native credential pickers and where they exist

An OS-level credential sheet, no browser, no URL bar, biometric confirmation, is the best experience available today. But they only exist for specific providers on specific platforms:

  • Apple Sign-In on iOS: ASAuthorizationController / ASAuthorizationAppleIDProvider (iOS 13+). No browser.
  • Google Sign-In on Android: Jetpack Credential Manager (androidx.credentials.CredentialManager). A system bottom sheet that surfaces signed-in Google accounts and passkeys.
  • Passkeys on iOS and Android: ASAuthorizationPlatformPublicKeyCredential* APIs on iOS and Credential Manager on Android.

There is one important asymmetry: iOS has no OS-level Google credential picker. Clerk's <AuthView /> on iOS routes Google Sign-In through ASWebAuthenticationSession, the same path Google's own GIDSignIn iOS SDK uses under the hood. The Safari sheet opens, the user taps "Continue with Google," and cookies are shared so most sign-ins are a single tap. But it is still a browser sheet, not a browserless OS picker.

On Android, the same <AuthView /> invokes Credential Manager and users see the real native bottom sheet. The Android path is the closest modern experience to Apple's on-device credential sheet.

App Store Guideline 4.8

Apple's App Store Review Guidelines require apps that offer any third-party social login to also offer an equivalent privacy-preserving option (Sign in with Apple qualifies) (Apple Developer, updated 2026-02-06). Enforcement began on 2020-06-30 (Apple Developer, 2020). Practically, this means if you ship Google Sign-In, you also ship Apple Sign-In, and the path of least friction is the native ASAuthorizationAppleIDButton and credential sheet.

A related requirement, Guideline 5.1.1(v), mandates in-app account deletion since 2022-06-30 (Apple Developer, 2022). Clerk's <UserProfileView /> surfaces account deletion as part of the native profile flow, so this ships for free.

The numbers

Measurable conversion gains for native and passkey flows are well-documented:

The pattern is consistent: when sign-in drops below a browser hop and uses on-device credentials, success rates climb and friction collapses.

How Clerk's native components render SwiftUI

@clerk/expo/native exports three React components. Each one is backed by a real SwiftUI view on iOS, hosted inside React Native's Fabric hierarchy via UIHostingController (Apple Developer). Props flow React → C++ Shadow Node → SwiftUI view; events flow back through Fabric's EventEmitter. The TypeScript surface hides every bit of that plumbing.

The end-to-end bridge looks like this:

React / TypeScript
   <AuthView mode="signInOrUp" />


   Fabric C++ Shadow Node  (props down, events up)

   ┌──────────┴──────────┐
   ▼                     ▼
iOS                   Android
UIHostingController   ComposeView
   │                     │
   ▼                     ▼
SwiftUI view          Jetpack Compose view
(clerk-ios)           (clerk-android)
   │                     │
   ▼                     ▼
ASAuthorizationController,   Credential Manager,
ASWebAuthenticationSession   Chrome Custom Tabs
   │                     │
   └──────────┬──────────┘

    native Clerk session


    JS Clerk instance → useAuth(), useUser(), useSession()

The actual UI lives in the clerk-ios native SDK, which ships as a native dependency of @clerk/expo. When a user signs in via the SwiftUI AuthView, the native SDK creates a session and hands it back through the Fabric event emitter. Since @clerk/expo 3.1.6, the JavaScript Clerk instance syncs bidirectionally with this native session, so useAuth(), useUser(), and useSession() reflect the new state without a page reload (@clerk/expo CHANGELOG).

Compare that to rolling your own SwiftUI integration. Callstack's "Exposing SwiftUI views to React Native" guide (Callstack, 2024) lays out the three-layer architecture you'd own: a SwiftUI view, an Objective-C++ wrapper inheriting from RCTViewComponentView, and an ObservableObject for prop passing. Add a Codegen spec, a Podspec for the native dependency, and lifecycle handling for the hosting controller. Cross-platform parity to Jetpack Compose is a second, separate effort.

Android parity

The same React API renders as Jetpack Compose on Android via Clerk's clerk-android SDK. You write one <AuthView /> import and get SwiftUI on iOS and Compose on Android, with per-platform Google, Apple, and passkey flows. The rest of this tutorial concentrates on iOS; Android-specific bits (Google SHA-1 fingerprint, Credential Manager behaviour) are flagged inline when they apply.

Comparing native React Native authentication approaches

Four broad options exist when you want real native auth on React Native:

  1. Build your own bridge. Obj-C++ wrapper around RCTViewComponentView, UIHostingController hosting a SwiftUI view, repeat for Compose on Android. High effort, high maintenance (Callstack integration guide).
  2. Redirect-based OAuth libraries. react-native-app-auth, Auth0 React Native, Amplify social login, the Supabase default path, Okta. All route through ASWebAuthenticationSession on iOS and Chrome Custom Tabs on Android. Compliant with RFC 8252 and fine for security; still shows browser chrome.
  3. Native credential-picker wrappers. expo-apple-authentication, @react-native-google-signin/google-signin, invertase/react-native-apple-authentication, and similar packages. They expose the native picker, but you still design every pixel of your sign-in, sign-up, and profile screens.
  4. Pre-built native RN UI. Clerk's @clerk/expo/native is currently the only option that gives you the full set of auth screens, social + email + MFA + profile, as drop-in React components.
ProviderPre-built RN UINative Google pickerNative Apple pickerProfile UIOfficial RN SDK
Clerk
Firebase Auth3rd party3rd party
Auth0
Supabase Auth3rd party3rd partyJS SDK
AWS AmplifyAuthenticator
StytchStytchUI
DescopeFlowViewvia optionvia option
WorkOS

A few notes the table flattens:

  • Firebase does not ship pre-built React Native UI. Its documented path is to combine @react-native-firebase/auth with @react-native-google-signin/google-signin and invertase/react-native-apple-authentication, wiring the tokens back into Firebase (rnfirebase.io, Social Auth).
  • Auth0 relies on Universal Login, an ASWebAuthenticationSession redirect. There is no native-form option (Auth0 React Native Quickstart).
  • Supabase does not expose native React Native UI. Their Apple Sign-In guide walks you through expo-apple-authentication, and their Google Sign-In guide uses @react-native-google-signin/google-signin (Supabase Apple; Supabase Google).
  • Amplify Authenticator exists for React Native but routes social providers through a browser-based flow (Amplify Authenticator; Amplify social providers).
  • Stytch ships a React Native UI configuration that renders a form, but their native social pickers are wrapper-level rather than system-level (Stytch React Native UI).
  • Descope offers a native-vs-browser toggle per flow; it's flexible, but there is no drop-in profile UI (Descope native vs browser flows).
  • WorkOS does not publish a React Native SDK (WorkOS SDKs list).

If you need full native UI with the least custom code, Clerk is the shortest path today. The rest of this tutorial walks through the implementation.

Prerequisites

Before you start, make sure you have:

  • Node.js 20.9.0+ (@clerk/expo minimum per its README). React Native 0.81 raises that floor in practice to 20.19.4+, so install the latest LTS Node if you're on an older machine.
  • Expo SDK 54+ (SDK 55 also works). This tutorial pins 54 to match Clerk's NativeComponentQuickstart repo.
  • Xcode 16.1+ (required by React Native 0.81 which ships with SDK 54).
  • A Clerk account. The free Hobby tier includes 50,000 monthly retained users (MRUs: users who visit your app on a day after sign-up).
  • Google Cloud Console access for creating OAuth 2.0 client IDs. Free.
  • iOS device and Apple ID setup (read carefully):
    • A physical iPhone is strongly recommended for the Apple Sign-In section. The iOS Simulator can complete the initial Sign in with Apple authorization flow when a real Apple ID is signed into Settings, but getCredentialStateAsync always throws on the Simulator, so any app logic that checks credential state on subsequent launches won't work there (expo-apple-authentication docs). Google Sign-In via <AuthView /> works reliably on the Simulator.
    • A free Apple ID builds to the Simulator. Deploying to a physical device and enabling the Sign in with Apple capability for App Store distribution both require an Apple Developer Program membership ($99/year) (Apple Developer Program).
  • Android parity (optional): Android Studio + Android SDK 24+ if you also want npx expo run:android. The Jetpack androidx.credentials Credential Manager library supports Android 6.0 (API 23) and higher (Android Credential Manager).

A quick version check:

node --version     # v20.9.0 or newer
xcodebuild -version # Xcode 16.1 or newer

If either is too old, upgrade before proceeding.

Why development builds, not Expo Go

Expo Go bundles a fixed set of native modules. @clerk/expo/native ships custom native code (the SwiftUI ClerkAuthView, the Jetpack Compose equivalent, and the clerk-ios / clerk-android native SDK dependencies). Expo Go can't load custom native modules, so it can't render these components (Expo development builds introduction).

expo-dev-client replaces Expo Go. It's the same Debug build of your app, with the Expo Dev Menu and fast refresh wired in (expo-dev-client). You install the package once; each npx expo run:ios compiles a fresh dev build for your device or simulator.

Two paths to a dev build:

  1. Local. npx expo run:ios / npx expo run:android. Zero cloud setup, requires Xcode or Android Studio locally.
  2. EAS. eas build --profile development. Cloud builder, no local Xcode needed. Requires an Expo account and, for iOS device builds, a paid Apple Developer Program membership (EAS development build).

This tutorial uses local builds for zero-setup reproducibility. EAS is a drop-in swap if that fits your team better.

Step 1: Create the Expo project

Scaffold a fresh project:

npx create-expo-app@latest clerk-native-app --template default
cd clerk-native-app

Then install Clerk and the two required Expo packages:

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

Three notes:

  • @clerk/expo is the Clerk SDK. The native components live under the @clerk/expo/native subpath.
  • expo-secure-store is how the Clerk token cache persists the session JWT securely on device (Expo SecureStore).
  • expo-dev-client turns your build into a dev client (replacing Expo Go).

Do not install expo-apple-authentication or expo-crypto yet. <AuthView /> handles Apple Sign-In internally without them. You'll only need those packages if you later swap to the custom-UI path with useSignInWithApple(), covered in Step 6. This matches the NativeComponentQuickstart package.json.

Step 2: Configure Clerk

There are four pieces of configuration: the Dashboard application, the Native API, environment variables, and the Clerk plugin in app.json.

Create a Clerk application

Sign up for a Clerk account and create a new application. Pick a name, leave the defaults, and copy the Publishable Key from the API keys page.

Enable the Native API

In the Dashboard, navigate to Native applications and click Enable. Register your iOS and Android apps (Clerk iOS production docs; Clerk Android production docs):

  • iOS: Apple Team ID + Bundle ID. Team ID is on the Apple Developer account membership page. Bundle ID matches the ios.bundleIdentifier value in your app.json, for example com.yourname.clerknativeapp.
  • Android: Package name + SHA-256 debug fingerprint. Get the fingerprint with:
cd android && ./gradlew signingReport

Use the SHA-256 line under Variant: debug. Production apps need the release-signing SHA-256 registered separately.

Configure environment variables

Create a .env.local file at the project root:

EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...

The EXPO_PUBLIC_ prefix is required for Expo to inline the value into the client bundle.

Configure the Clerk plugin in app.json

Add the @clerk/expo plugin alongside expo-router and expo-secure-store:

{
  "expo": {
    "name": "clerk-native-app",
    "slug": "clerk-native-app",
    "ios": { "bundleIdentifier": "com.yourname.clerknativeapp" },
    "android": { "package": "com.yourname.clerknativeapp" },
    "plugins": ["expo-router", "expo-secure-store", ["@clerk/expo", {}]]
  }
}

The plugin accepts two options, verified against withClerkExpo.ts in @clerk/expo 3.2.0 (plugin source):

  • appleSignIn: true (default). The plugin writes the com.apple.developer.applesignin entitlement at prebuild time so Sign in with Apple works out of the box. Set it to false only if you're certain you don't need Apple Sign-In.
  • theme: './clerk-theme.json' (see the Theming section later).

The plugin also reads EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME from the environment if you opt into the native GIDSignIn path via useSignInWithGoogle(). That's a more advanced use case; <AuthView /> doesn't need it.

Note

The published plugin source does not expose a keychainService / App Group option. Keychain sharing between app and extensions lives on the clerk-ios Clerk.Options API and requires a custom config plugin from Expo. See the FAQ for details.

Wrap the app with <ClerkProvider>

Open app/_layout.tsx and wrap the root <Stack> with <ClerkProvider>. Use the tokenCache export from @clerk/expo/token-cache so sessions persist across restarts:

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

const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!

export default function RootLayout() {
  return (
    <ClerkProvider publishableKey={publishableKey} tokenCache={tokenCache}>
      <Stack />
    </ClerkProvider>
  )
}

The token cache stores the Clerk session JWT in expo-secure-store, which maps to iOS Keychain and Android EncryptedSharedPreferences (tokenCache source). Restart the app and the previous session rehydrates.

Step 3: Create the development build

From the project root:

npx expo run:ios

The first build compiles React Native and takes several minutes. Subsequent builds cache and finish in tens of seconds. Expo launches the iOS Simulator and runs your app once the build completes. Add --device to deploy to a connected iPhone:

npx expo run:ios --device

Success criteria: the app launches with the default Expo welcome screen and no red or yellow boxes. If you see "Native module cannot be null" or similar, you're running in Expo Go or against a stale dev build. Re-run npx expo run:ios.

For Android parity, start an emulator and run:

npx expo run:android

See the Expo iOS development build tutorial if you run into device provisioning issues.

Step 4: Add Google Sign-In with <AuthView />

Google Sign-In needs two places to know about each other: Google Cloud Console (which issues the client IDs) and the Clerk Dashboard (which uses them).

Google Cloud Console setup (iOS primary)

In the Google Cloud Console:

  1. Create an OAuth consent screen (External, fill the minimum fields). This is mandatory before you can create OAuth client IDs.
  2. Create the OAuth 2.0 client IDs you need:
    • iOS client ID (required): use the Bundle ID from your app.json.
    • Web client ID (required): Clerk uses this for server-side token verification.
    • Android client ID (only if building Android): package name plus the SHA-1 debug fingerprint from ./gradlew signingReport. Production builds need a separate client ID registered with the release SHA-1.

Google's native-apps guide has the canonical walkthrough (Google Identity, OAuth for Native Apps).

Paste credentials into Clerk Dashboard

In the Dashboard, go to Social Connections → Google and paste:

  • iOS Client ID.
  • Web Client ID.
  • Web Client Secret.
  • Android Client ID (if applicable).

That's it for configuration. Clerk's config plugin handles the iOS URL scheme; you do not edit Info.plist by hand.

Render <AuthView />

Create app/sign-in.tsx:

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

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

The mode prop accepts three values, verified in AuthView.types.ts:

  • 'signIn': sign-in only.
  • 'signUp': sign-up only.
  • 'signInOrUp' (default): combined flow. The native UI decides based on whether the identifier exists.

The only other prop is isDismissable: boolean (default false) for presentations where you want the user to be able to close the sheet. There is no style or appearance prop; theming lives in clerk-theme.json (see the Theming section).

React to auth state changes

Pair <AuthView /> with useAuth() to navigate after sign-in completes. On a fresh native sign-in, there's a short window while the native session syncs into the JavaScript Clerk instance; during that window the JS layer sees a pending state. Tell useAuth to treat pending as signed-in so your useEffect doesn't misfire:

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

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

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

  return <AuthView mode="signInOrUp" />
}

The treatPendingAsSignedOut: false flag deserves a proper explanation. Since @clerk/clerk-react 5.26.0 (April 2025, PR #5507), which @clerk/expo depends on, useAuth() defaults treatPendingAsSignedOut to true. "Pending" is Clerk's generic state for "a session exists but requires an additional step" (for example, MFA not yet completed). In the native-components case, pending shows up during the native-session → JS-Clerk sync window: clerk-ios has already created a session, and @clerk/expo is still syncing it into the React tree. A useEffect watching isSignedIn with the default true would briefly see signed-out and fire, causing a redirect flicker. Setting treatPendingAsSignedOut: false bridges the gap for screens that react to native authentication.

Test the flow

Run the dev build and tap the Google button inside <AuthView />:

npx expo run:ios

iOS has no OS-level Google credential picker. <AuthView /> opens an ASWebAuthenticationSession Safari sheet, the same sheet Google's own GIDSignIn iOS SDK uses. The user sees a system-managed Safari interface asking permission to sign in; cookies shared with Safari mean most users continue with one tap. This is the RFC 8252 compliant flow, with browser chrome visible rather than a browserless OS picker.

On Android, the same <AuthView /> opens the Credential Manager bottom sheet (Android Credential Manager for Sign in with Google), the fully native experience. The contrast is deliberate: iOS doesn't expose an equivalent OS API.

You can cross-reference the full sample code in Clerk's NativeComponentQuickstart app/index.tsx.

Step 5: User management with <UserButton /> and useUserProfileModal()

Post-auth UX usually needs two things: a visible affordance for the user to manage their account, and a place to do it. <UserButton /> is the affordance; <UserProfileView /> is the place.

Place <UserButton /> in the header

UserButton has no props. Size is controlled by the parent <View> using width, height, borderRadius, and overflow: 'hidden' (UserButton.tsx source). Put it in the Expo Router header's headerRight:

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

export default function HomeLayout() {
  return (
    <Stack
      screenOptions={{
        headerRight: () => (
          <View style={{ width: 44, height: 44, borderRadius: 22, overflow: 'hidden' }}>
            <UserButton />
          </View>
        ),
      }}
    >
      <Stack.Screen name="index" options={{ title: 'Home' }} />
    </Stack>
  )
}

Tapping the button opens the native profile modal, a real SwiftUI sheet on iOS, not a React Native <Modal />.

Use useUserProfileModal() to open the profile imperatively

If you want a button in another screen (say, a settings row) to open the profile, use the useUserProfileModal() hook (useUserProfileModal.ts):

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

export function AccountRow() {
  const { presentUserProfile, isAvailable } = useUserProfileModal()
  if (!isAvailable) return null
  return <Button title="Account" onPress={presentUserProfile} />
}

isAvailable is false on platforms where the native modal isn't supported (for example, web), so gate on it.

Inline <UserProfileView /> for dedicated profile screens

If you prefer a full-screen profile route, render <UserProfileView /> inline:

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

export default function ProfileScreen() {
  return <UserProfileView style={{ flex: 1 }} />
}

Of the three native components, UserProfileView is the only one that accepts a style prop (UserProfileView.tsx source). Use it to size and position the inline view; visual theming is still controlled by clerk-theme.json.

Sign-out flow

<UserProfileView />'s built-in Sign Out button emits a signed-out event. Thanks to two-way session sync (introduced in @clerk/expo 3.1.6), the JavaScript Clerk instance updates automatically, so useAuth().isSignedIn flips to false and any Stack.Protected gate you've set up re-renders. If you want to react explicitly (log analytics, clear app state), use useNativeAuthEvents():

import { useNativeAuthEvents } from '@clerk/expo'
import { useRouter } from 'expo-router'

export function useSignOutRedirect() {
  const router = useRouter()
  useNativeAuthEvents({
    onSignedOut: () => router.replace('/sign-in'),
  })
}

The hook emits signedIn and signedOut events from the native SDKs via NativeEventEmitter (useNativeAuthEvents.ts).

Step 6: Add Apple Sign-In

Apple Sign-In is the only provider on iOS that renders a true native credential sheet via ASAuthorizationController. On Android, Apple falls back to an OAuth browser flow that <AuthView /> handles transparently. The @clerk/expo config plugin writes the necessary iOS entitlement at prebuild time, so the wiring is minimal.

Enable Apple in the Dashboard

In the Clerk Dashboard, go to Social Connections → Apple and enable it. For development, that's enough; you can test on a physical iPhone signed into a real Apple ID.

For production you'll also supply the Apple-side credentials (Clerk Apple social connection setup):

Rebuild the dev build

The @clerk/expo plugin adds the com.apple.developer.applesignin entitlement at prebuild time, which is a native change. Rebuild:

npx expo run:ios --device

A simulator rebuild works for most of the flow, but physical-device testing is required for reliable end-to-end Apple Sign-In (see prerequisites).

Testing without a physical iPhone

If you don't have a device on hand, you can still make progress. The Sign in with Apple sheet will render on the Simulator when an Apple ID is signed into Settings → [your name] → Media & Purchases, and the initial authorization sometimes completes if that Apple ID has 2FA enabled (Apple Developer Forums discussion). getCredentialStateAsync always throws on the Simulator, though, so any logic that checks credential state on subsequent launches will fail there.

Two pragmatic fallbacks:

  • Mock the native modules for unit tests and CI. Expo's official mocking guide covers the jest-expo preset and module mocks so your Apple Sign-In tests run green on a laptop with no device attached.
  • Borrow, ad-hoc, or rent a real device before release. A personal device, an internal-distribution EAS build on a teammate's phone, or a cloud device farm (AWS Device Farm, BrowserStack, Firebase Test Lab) all work for pre-release verification. Apple's App Review requires a working Sign in with Apple implementation (App Store Review Guideline 4.8), so at least one physical-device pass is mandatory before shipping to the App Store.

Test Apple Sign-In with <AuthView />

<AuthView /> detects that Apple is enabled for your Clerk instance and renders the ASAuthorizationAppleIDButton automatically (Apple Developer). Tap it and the OS presents the credential sheet, biometric prompt, email relay option, all rendered by ASAuthorizationController. Clerk handles the sign-in → sign-up transfer flow internally, so a returning user with a Clerk account is signed in; a new user triggers a sign-up with the name and email that Apple provides on first authorization.

One OS constraint to plan for: Apple only returns the user's fullName and email on the first authorization. Subsequent sign-ins omit them. Clerk stores them automatically on first auth, so you don't need to handle this in your app, but it's worth knowing if you ever see a user with no name on re-sign-in during testing.

Optional: custom Apple Sign-In UI with useSignInWithApple()

If you need a branded Apple button that lives outside <AuthView /> (custom placement, haptics, extra unsafeMetadata, or interleaving with non-Clerk screens), use the useSignInWithApple() hook from the /apple subpath. @clerk/expo 3.0.0 moved the hook to a dedicated /apple entry point, so projects that don't need Apple Sign-In don't bundle expo-apple-authentication and expo-crypto.

Install the two additional packages and register expo-apple-authentication as an Expo config plugin so its entitlements and native dependencies are applied at prebuild (Clerk Sign in with Apple (Expo); expo-apple-authentication config plugin):

npx expo install expo-apple-authentication expo-crypto

Register the plugin in app.json:

{
  "expo": {
    "plugins": [
      "expo-router",
      "expo-secure-store",
      "expo-apple-authentication",
      ["@clerk/expo", {}]
    ]
  }
}

Then build the custom button. Import the hook from @clerk/expo/apple and gate rendering on Platform.OS === 'ios' because the hook is iOS-only (useSignInWithApple.ios.ts):

import { useSignInWithApple } from '@clerk/expo/apple'
import { Platform, Pressable, Text } from 'react-native'
import { useRouter } from 'expo-router'

export function AppleButton() {
  const { startAppleAuthenticationFlow } = useSignInWithApple()
  const router = useRouter()
  if (Platform.OS !== 'ios') return null

  async function onPress() {
    try {
      const { createdSessionId, setActive } = await startAppleAuthenticationFlow()
      if (createdSessionId && setActive) {
        await setActive({ session: createdSessionId })
        router.replace('/')
      }
    } catch (err: any) {
      if (err.code !== 'ERR_REQUEST_CANCELED') console.error(err)
    }
  }

  return (
    <Pressable onPress={onPress}>
      <Text>Sign in with Apple</Text>
    </Pressable>
  )
}

The hook auto-generates a cryptographic nonce via expo-crypto, requests FULL_NAME + EMAIL scopes, and transparently transfers a sign-in into a sign-up when the Apple ID is new to Clerk. User cancellation is usually swallowed inside the hook and resolves with createdSessionId: null (no throw), but the ERR_REQUEST_CANCELED guard handles edge paths where it surfaces (Clerk useSignInWithApple reference).

For Android, Apple authentication goes through useSSO({ strategy: 'oauth_apple' }) instead, which uses a browser flow under the hood (useSSO() reference).

When to pick useSignInWithApple vs <AuthView />

<AuthView /> is the default path: drop-in native UI for Apple + Google + email/password + MFA + recovery, no manual dependency wiring, automatic entitlement management. Pick useSignInWithApple() only when you need a fully custom button UI that composes into an otherwise non-Clerk screen. Mixing the two inside a single sign-in flow is possible but usually unnecessary.

Apple first shipped the useSignInWithApple() hook for Expo on 2025-11-13 (Clerk changelog, 2025-11-13).

Step 7: Protect routes with Expo Router

Only signed-in users should reach the home stack. The cleanest pattern on Expo Router v5+ is Stack.Protected (Expo Router authentication guide; Stack.Protected reference):

import { Stack } from 'expo-router'
import { useAuth } from '@clerk/expo'

export default function RootLayout() {
  const { isSignedIn, isLoaded } = useAuth()
  if (!isLoaded) return null

  return (
    <Stack>
      <Stack.Protected guard={isSignedIn}>
        <Stack.Screen name="(home)" />
      </Stack.Protected>
      <Stack.Protected guard={!isSignedIn}>
        <Stack.Screen name="sign-in" />
      </Stack.Protected>
    </Stack>
  )
}

The isLoaded check prevents a flash of the wrong route while Clerk's hydration completes. On Expo SDK 52 and earlier, use the legacy pattern with useEffect and router.replace(). Stack.Protected shipped with Expo Router v5.

If you prefer render-level gating instead of navigation-level gating, Clerk ships a <Show> component that replaces the legacy <SignedIn> / <SignedOut> / <Protect> triplet (Clerk <Show> reference). Both approaches work; Stack.Protected is usually cleaner for mobile flows.

Under the hood: the Core 3 Signal API

Note

Skip this section if you're only using <AuthView />, <UserButton />, and <UserProfileView />. The native components render directly through clerk-ios (SwiftUI) and clerk-android (Jetpack Compose) and do not call the JavaScript Signal API. @clerk/expo syncs the resulting session into useAuth(), useUser(), and useSession() automatically, so you get reactive React state without writing any sign-in orchestration code.

Come back here the first time you need to render a custom sign-in, sign-up, or MFA screen yourself. The Signal API is the supported JavaScript path for that; the native components are the default path when the pre-built UI works.

Clerk Core 3 shipped on 2026-03-03 (Clerk changelog, 2026-03-03), and @clerk/expo 3.1 brought it to Expo alongside the native components on 2026-03-09 (Clerk changelog, 2026-03-09). The Signal API is a redesign of useSignIn, useSignUp, useCheckout, and useWaitlist. Each hook returns a reactive *Future object (SignInFuture, SignUpFuture) that triggers re-renders automatically and exposes three structured fields:

  • fetchStatus ('idle' | 'fetching') replaces manual setIsLoading(true) booleans.
  • status ('needs_first_factor' | 'needs_second_factor' | 'complete' and so on) tells you where you are in the flow.
  • errors.fields gives you parsed, field-scoped errors, no try/catch parsing of ClerkAPIError[].

The canonical sign-in shape is:

signIn.create({ identifier }) // initialize with email, phone, or username
signIn.password({ password }) // authenticate with password
signIn.finalize({ navigate }) // completes the flow (replaces setActive())

Other first-factor methods follow the same pattern: signIn.emailCode.sendCode() / .verifyCode({ code }), signIn.passkey(), signIn.mfa.verifyTOTP({ code }), and so on (Clerk MFA custom flow guide). The navigate callback passed to finalize() receives { session, decorateUrl } so you can branch on session.currentTask (forced MFA setup, org selection, and similar "requires additional step" branches).

A minimal custom email/password screen:

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

export default function CustomSignIn() {
  const { signIn, errors, fetchStatus } = useSignIn()
  const router = useRouter()
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')

  async function onSubmit() {
    await signIn.create({ identifier: email })
    await signIn.password({ password })
    if (signIn.status === 'complete') {
      await signIn.finalize({ navigate: () => router.replace('/') })
    }
  }

  return (
    <View>
      <TextInput value={email} onChangeText={setEmail} autoCapitalize="none" />
      {errors.fields.identifier && <Text>{errors.fields.identifier.message}</Text>}
      <TextInput value={password} onChangeText={setPassword} secureTextEntry />
      <Button title={fetchStatus === 'fetching' ? 'Signing in...' : 'Sign in'} onPress={onSubmit} />
    </View>
  )
}

Legacy Core 2 required wrapping each call in try { ... } catch (err) { /* parse ClerkAPIError[] */ }. Core 3 exposes parsed errors on the hook itself (errors.fields.identifier?.message, errors.fields.password?.message, plus errors.global and raw errors.raw), so the component code stays linear.

<AuthView /> does not call the JavaScript Signal API. It renders through the native Clerk SDKs (clerk-ios SwiftUI, clerk-android Compose), which implement the equivalent flow natively. When the native SDK completes authentication, @clerk/expo syncs the session into the JS Clerk instance, so useAuth(), useUser(), and useSession() all reflect the new state. The Signal API hooks are the custom-UI escape hatch when you need to render the sign-in screen yourself.

One stability caveat worth flagging: the SignInFuture instance does not have a stable identity across flow steps. If you reference signIn inside useEffect, useCallback, or useMemo, include it in the dependency array explicitly rather than relying on React identity stability (SignInFuture reference).

Theming the native components

@clerk/expo 3.2.0 (shipped 2026-04-16) added a theme plugin option that points at a JSON file. Tokens are embedded at prebuild time, so the customization lives at the native layer rather than JS runtime.

Create clerk-theme.json at the project root:

{
  "colors": {
    "primary": "#6C47FF",
    "background": "#FFFFFF",
    "foreground": "#0A0A0A"
  },
  "darkColors": {
    "background": "#121212",
    "foreground": "#F5F5F5"
  },
  "design": {
    "borderRadius": 12
  }
}

Reference it in the plugin config:

["@clerk/expo", { "theme": "./clerk-theme.json" }]

Fifteen color tokens are available in the light colors block: primary, background, input, danger, success, warning, foreground, mutedForeground, primaryForeground, inputForeground, neutral, border, ring, muted, and shadow. The darkColors block accepts the same tokens (override any you want), plus design.borderRadius and an iOS-only design.fontFamily. Rebuild after changing the file because values are baked in at prebuild time (NativeComponentQuickstart clerk-theme.json).

Offline session rehydration

Mobile apps live with flaky networks. @clerk/expo ships an experimental resource cache that persists environment, client state, and the last session JWT to expo-secure-store, so the app boots into an authenticated state on cold start without a network roundtrip. Opt in by passing resourceCache as the __experimental_resourceCache prop on <ClerkProvider> (Clerk offline support):

import { ClerkProvider } from '@clerk/expo'
import { tokenCache } from '@clerk/expo/token-cache'
import { resourceCache } from '@clerk/expo/resource-cache'
import { Stack } from 'expo-router'

const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!

export default function RootLayout() {
  return (
    <ClerkProvider
      publishableKey={publishableKey}
      tokenCache={tokenCache}
      __experimental_resourceCache={resourceCache}
    >
      <Stack />
    </ClerkProvider>
  )
}

Three things are cached: the Clerk environment (enabled auth strategies, organization settings, feature flags), client state (active sessions, the user object), and the session JWT. Behind the scenes, resourceCache chunks values across expo-secure-store slots with keychainAccessible: AFTER_FIRST_UNLOCK, so the cache is readable only after the user has unlocked the device at least once per boot (resource-cache.ts source).

What it does and does not do:

  • Rehydration, yes. On a cold start with no network, useAuth() resolves from cache and getToken() returns the last cached JWT. Your gated routes render immediately.
  • Offline sign-in, no. signIn.create(), signIn.password(), <AuthView />, and the native social flows all still require network. The resource cache accelerates the "already signed in" path, not the first authentication.
  • Staleness applies. Cached tokens can be stale until the next refresh, so always treat server-side verification as the source of truth when gating sensitive data.

The __experimental_ prefix reflects that the shape may change. Clerk's docs warn: "It is subject to change in future updates, so use it cautiously in production environments" (Clerk offline support). The older @clerk/expo/secure-store export covered similar ground and is now deprecated in favor of resourceCache.

Troubleshooting

Most native-component issues fall into a handful of buckets. Here's what to check first.

"Native module not found" or TurboModuleRegistry errors. You're running in Expo Go or an old dev build. Rebuild with npx expo run:ios or npx expo run:android.

Google Sign-In error code: 10 on Android. The SHA-1 fingerprint you registered in the Google Cloud Console doesn't match the signing key of the build currently on the device. Re-run ./gradlew signingReport, copy the debug SHA-1, and update the Android OAuth client ID. Release builds use a different fingerprint (React Native Google Sign-In, Setting Up).

Apple Sign-In "hangs" on the simulator. You're hitting the Simulator's getCredentialStateAsync gap. Move to a physical iPhone signed into a real Apple ID (expo-apple-authentication docs).

Kotlin metadata version mismatch on Android. Fixed in @clerk/expo 3.1.5. The config plugin now adds the -Xskip-metadata-version-check Kotlin compiler flag at prebuild time, so builds against Expo SDK 54/55 stop failing with mismatched metadata errors. Upgrade to 3.1.5 or newer (@clerk/expo CHANGELOG).

OAuth redirect URI mismatch. Clerk's default mobile SSO redirect is {bundleIdentifier}://callback. Verify the Dashboard's "Allowlist for mobile SSO redirect" matches your app's Bundle ID (Clerk iOS production docs).

White flash on AuthView mount (iOS). Fixed in @clerk/expo 3.1.10.

useAuth() reports signed-out briefly after a successful native sign-in. This is the native-session → JS-Clerk sync window. clerk-ios has already created the session (as pending); @clerk/expo is still syncing it into the React tree. Since useAuth() defaults treatPendingAsSignedOut: true, the hook reads pending as signed-out. Set useAuth({ treatPendingAsSignedOut: false }) anywhere you watch isSignedIn after a native flow to bridge the gap.

Frequently asked questions

Next steps

  • Add passkeys. Install @clerk/expo-passkeys and pass __experimental_passkeys to <ClerkProvider> to enable passkey sign-in and enrollment inside <AuthView /> and <UserProfileView />. Works on iOS 16+ and Android 9+ (Clerk Expo passkeys).
  • Biometric sign-in. Use useLocalCredentials() to authenticate with a stored password via Face ID or Touch ID on return visits. It's a password-strategy-only hook, so it complements <AuthView /> rather than replacing it (Clerk useLocalCredentials).
  • Authenticated backend calls. Use getToken() from useAuth() to retrieve a short-lived session JWT and send it as an Authorization: Bearer header to your own backend (Clerk session tokens).