
How to use SwiftUI components in a React Expo and Clerk app
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.
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.Protectedso 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:
- Pinterest: 126% Android sign-up increase with Google One Tap (Google Identity, Pinterest case study).
- FIDO Passkey Index (Oct 2025): 93% sign-in success with passkeys vs 63% with other authentication methods (social login, MFA, OTPs) (FIDO Alliance, Oct 2025).
- Dashlane: 92% conversion rate on passkey sign-in opportunities vs 54% on automatic password opportunities, a 70% conversion lift (Google Developers Blog, Dashlane).
- Microsoft: ~98% success for passkeys vs ~32% for passwords; phishing attacks using adversary-in-the-middle techniques up 146% year-over-year (Microsoft Security, Dec 2024).
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:
- Build your own bridge. Obj-C++ wrapper around
RCTViewComponentView,UIHostingControllerhosting a SwiftUI view, repeat for Compose on Android. High effort, high maintenance (Callstack integration guide). - Redirect-based OAuth libraries.
react-native-app-auth, Auth0 React Native, Amplify social login, the Supabase default path, Okta. All route throughASWebAuthenticationSessionon iOS and Chrome Custom Tabs on Android. Compliant with RFC 8252 and fine for security; still shows browser chrome. - 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. - Pre-built native RN UI. Clerk's
@clerk/expo/nativeis currently the only option that gives you the full set of auth screens, social + email + MFA + profile, as drop-in React components.
A few notes the table flattens:
- Firebase does not ship pre-built React Native UI. Its documented path is to combine
@react-native-firebase/authwith@react-native-google-signin/google-signinandinvertase/react-native-apple-authentication, wiring the tokens back into Firebase (rnfirebase.io, Social Auth). - Auth0 relies on Universal Login, an
ASWebAuthenticationSessionredirect. 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/expominimum 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
getCredentialStateAsyncalways 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).
- 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
- Android parity (optional): Android Studio + Android SDK 24+ if you also want
npx expo run:android. The Jetpackandroidx.credentialsCredential 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 newerIf 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:
- Local.
npx expo run:ios/npx expo run:android. Zero cloud setup, requires Xcode or Android Studio locally. - 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-appThen install Clerk and the two required Expo packages:
npx expo install @clerk/expo expo-secure-store expo-dev-clientThree notes:
@clerk/expois the Clerk SDK. The native components live under the@clerk/expo/nativesubpath.expo-secure-storeis how the Clerk token cache persists the session JWT securely on device (Expo SecureStore).expo-dev-clientturns 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.bundleIdentifiervalue in yourapp.json, for examplecom.yourname.clerknativeapp. - Android: Package name + SHA-256 debug fingerprint. Get the fingerprint with:
cd android && ./gradlew signingReportUse 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 thecom.apple.developer.applesigninentitlement at prebuild time so Sign in with Apple works out of the box. Set it tofalseonly 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.
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:iosThe 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 --deviceSuccess 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:androidSee 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:
- Create an OAuth consent screen (External, fill the minimum fields). This is mandatory before you can create OAuth client IDs.
- 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.
- iOS client ID (required): use the Bundle ID from your
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:iosiOS 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):
- Apple Team ID.
- Services ID (created in the Apple Developer portal).
- Key ID and
.p8private key file (one-time download; back it up immediately per Apple's private-key guide).
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 --deviceA 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-expopreset 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-cryptoRegister 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
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 manualsetIsLoading(true)booleans.status('needs_first_factor' | 'needs_second_factor' | 'complete'and so on) tells you where you are in the flow.errors.fieldsgives you parsed, field-scoped errors, no try/catch parsing ofClerkAPIError[].
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 andgetToken()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-passkeysand pass__experimental_passkeysto<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 (ClerkuseLocalCredentials). - Authenticated backend calls. Use
getToken()fromuseAuth()to retrieve a short-lived session JWT and send it as anAuthorization: Bearerheader to your own backend (Clerk session tokens).