diff --git a/.changeset/large-aliens-arrive.md b/.changeset/large-aliens-arrive.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/large-aliens-arrive.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/integration/templates/expo-native/App.tsx b/integration/templates/expo-native/App.tsx
index bfac480af41..7c8802393e7 100644
--- a/integration/templates/expo-native/App.tsx
+++ b/integration/templates/expo-native/App.tsx
@@ -2,14 +2,22 @@ import { ClerkProvider, useAuth, useUser } from '@clerk/expo';
import { AuthView, UserButton, UserProfileView } from '@clerk/expo/native';
import { tokenCache } from '@clerk/expo/token-cache';
import { useState } from 'react';
-import { Button, Modal, StyleSheet, Text, View } from 'react-native';
+import { Button, LogBox, Modal, StyleSheet, Text, View } from 'react-native';
import { BiometricAvailabilityButton } from './components/BiometricAvailabilityButton';
import { GoogleSignInButton } from './components/GoogleSignInButton';
+import { readVerifyLaunch } from './verify/launch';
+import { logRequests, VerifyHost } from './verify/VerifyHost';
const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY;
+const verifyLaunch = readVerifyLaunch();
-if (!publishableKey) {
+if (verifyLaunch) {
+ LogBox.ignoreAllLogs();
+ if (verifyLaunch.debugLogging) {
+ logRequests();
+ }
+} else if (!publishableKey) {
throw new Error('Missing EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY');
}
@@ -111,6 +119,10 @@ function NativeBuildFixture() {
}
export default function App() {
+ if (verifyLaunch) {
+ return ;
+ }
+
return (
+ expoVersion.startsWith('57.')
+ ? { ...config, plugins: [...config.plugins, ['expo-build-properties', { ios: { enableSceneSupport: true } }]] }
+ : config;
diff --git a/integration/templates/expo-native/app.json b/integration/templates/expo-native/app.json
index 58e258ea523..185a1bc005f 100644
--- a/integration/templates/expo-native/app.json
+++ b/integration/templates/expo-native/app.json
@@ -12,6 +12,11 @@
"android": {
"package": "com.clerk.exponativebuildfixture"
},
- "plugins": ["expo-secure-store", "@clerk/expo", "expo-web-browser"]
+ "plugins": ["expo-secure-store", "@clerk/expo", "@clerk/expo-google-signin", "expo-web-browser"],
+ "extra": {
+ "EXPO_PUBLIC_CLERK_GOOGLE_WEB_CLIENT_ID": "000000000000-clerkverifyplaceholderweb.apps.googleusercontent.com",
+ "EXPO_PUBLIC_CLERK_GOOGLE_IOS_CLIENT_ID": "000000000000-clerkverifyplaceholderios.apps.googleusercontent.com",
+ "EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME": "com.googleusercontent.apps.000000000000-clerkverifyplaceholderios"
+ }
}
}
diff --git a/integration/templates/expo-native/components/GoogleSignInButton.tsx b/integration/templates/expo-native/components/GoogleSignInButton.tsx
index 61b3df286fd..2ffaa8fd2c0 100644
--- a/integration/templates/expo-native/components/GoogleSignInButton.tsx
+++ b/integration/templates/expo-native/components/GoogleSignInButton.tsx
@@ -12,10 +12,15 @@ export function GoogleSignInButton() {
testID='google-sign-in-button'
title='Sign in with Google'
onPress={() => {
- void startGoogleAuthenticationFlow().catch((error: unknown) => {
- const message = error instanceof Error ? error.message : String(error);
- setResult(message.replace(/\s+/g, ' '));
- });
+ setResult(null);
+ void startGoogleAuthenticationFlow().then(
+ ({ createdSessionId }) =>
+ setResult(createdSessionId ? 'Google sign-in created a session' : 'Google sign-in was cancelled'),
+ (error: unknown) => {
+ const message = error instanceof Error ? error.message : String(error);
+ setResult(`Google sign-in failed: ${message.replace(/\s+/g, ' ')}`);
+ },
+ );
}}
/>
{result && {result}}
diff --git a/integration/templates/expo-native/metro.config.js b/integration/templates/expo-native/metro.config.js
new file mode 100644
index 00000000000..58609d9362b
--- /dev/null
+++ b/integration/templates/expo-native/metro.config.js
@@ -0,0 +1,37 @@
+const fs = require('node:fs');
+const path = require('node:path');
+const { getDefaultConfig } = require('expo/metro-config');
+const { dependencies, devDependencies } = require('./package.json');
+
+const config = getDefaultConfig(__dirname);
+const monorepoRoot = path.resolve(__dirname, '../../..');
+const fixtureEntry = path.join(__dirname, 'index.js');
+const fixturePackages = new Set(Object.keys({ ...dependencies, ...devDependencies }));
+
+function linksToWorkspace() {
+ try {
+ return (
+ fs.realpathSync(path.join(__dirname, 'node_modules/@clerk/expo')) === path.join(monorepoRoot, 'packages/expo')
+ );
+ } catch {
+ return false;
+ }
+}
+
+function packageName(moduleName) {
+ const [scope, name] = moduleName.split('/');
+ return scope.startsWith('@') ? `${scope}/${name}` : scope;
+}
+
+if (linksToWorkspace()) {
+ config.watchFolders = [monorepoRoot];
+ config.resolver.resolveRequest = (context, moduleName, platform) => {
+ const fromWorkspace = !context.originModulePath.startsWith(__dirname + path.sep);
+ if (fromWorkspace && fixturePackages.has(packageName(moduleName))) {
+ return context.resolveRequest({ ...context, originModulePath: fixtureEntry }, moduleName, platform);
+ }
+ return context.resolveRequest(context, moduleName, platform);
+ };
+}
+
+module.exports = config;
diff --git a/integration/templates/expo-native/modules/verify-launch-config/android/build.gradle b/integration/templates/expo-native/modules/verify-launch-config/android/build.gradle
new file mode 100644
index 00000000000..308e30af523
--- /dev/null
+++ b/integration/templates/expo-native/modules/verify-launch-config/android/build.gradle
@@ -0,0 +1,15 @@
+plugins {
+ id 'com.android.library'
+ id 'expo-module-gradle-plugin'
+}
+
+group = 'expo.modules.verifylaunchconfig'
+version = '1.0.0'
+
+android {
+ namespace "expo.modules.verifylaunchconfig"
+ defaultConfig {
+ versionCode 1
+ versionName "1.0.0"
+ }
+}
diff --git a/integration/templates/expo-native/modules/verify-launch-config/android/src/main/AndroidManifest.xml b/integration/templates/expo-native/modules/verify-launch-config/android/src/main/AndroidManifest.xml
new file mode 100644
index 00000000000..bdae66c8f5a
--- /dev/null
+++ b/integration/templates/expo-native/modules/verify-launch-config/android/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/integration/templates/expo-native/modules/verify-launch-config/android/src/main/java/expo/modules/verifylaunchconfig/VerifyLaunchConfigModule.kt b/integration/templates/expo-native/modules/verify-launch-config/android/src/main/java/expo/modules/verifylaunchconfig/VerifyLaunchConfigModule.kt
new file mode 100644
index 00000000000..776aeadb730
--- /dev/null
+++ b/integration/templates/expo-native/modules/verify-launch-config/android/src/main/java/expo/modules/verifylaunchconfig/VerifyLaunchConfigModule.kt
@@ -0,0 +1,34 @@
+package expo.modules.verifylaunchconfig
+
+import android.content.Context
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
+
+private const val PREFERENCES = "verify_launch_config"
+private const val STORAGE_SCOPE_KEY = "storageScope"
+private val CLERK_STORAGE = listOf("clerk_preferences", "SecureStore")
+
+class VerifyLaunchConfigModule : Module() {
+ override fun definition() = ModuleDefinition {
+ Name("VerifyLaunchConfig")
+
+ Function("readLaunchInputs") {
+ val extras = appContext.currentActivity?.intent?.extras ?: return@Function emptyMap()
+ extras.keySet()
+ .filter { it.startsWith("verify") }
+ .mapNotNull { key -> extras.getString(key)?.let { key to it } }
+ .toMap()
+ }
+
+ Function("applyStorageScope") { scope: String ->
+ val context = appContext.reactContext ?: return@Function
+ val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
+ if (preferences.getString(STORAGE_SCOPE_KEY, null) == scope) {
+ return@Function
+ }
+
+ CLERK_STORAGE.forEach { context.deleteSharedPreferences(it) }
+ preferences.edit().putString(STORAGE_SCOPE_KEY, scope).commit()
+ }
+ }
+}
diff --git a/integration/templates/expo-native/modules/verify-launch-config/expo-module.config.json b/integration/templates/expo-native/modules/verify-launch-config/expo-module.config.json
new file mode 100644
index 00000000000..0d8cc5ab8c0
--- /dev/null
+++ b/integration/templates/expo-native/modules/verify-launch-config/expo-module.config.json
@@ -0,0 +1,9 @@
+{
+ "platforms": ["apple", "android"],
+ "apple": {
+ "modules": ["VerifyLaunchConfigModule"]
+ },
+ "android": {
+ "modules": ["expo.modules.verifylaunchconfig.VerifyLaunchConfigModule"]
+ }
+}
diff --git a/integration/templates/expo-native/modules/verify-launch-config/index.ts b/integration/templates/expo-native/modules/verify-launch-config/index.ts
new file mode 100644
index 00000000000..ca15ce01a7b
--- /dev/null
+++ b/integration/templates/expo-native/modules/verify-launch-config/index.ts
@@ -0,0 +1,8 @@
+import { requireOptionalNativeModule } from 'expo';
+
+type VerifyLaunchConfigModule = {
+ readLaunchInputs(): Record;
+ applyStorageScope(scope: string): void;
+};
+
+export const VerifyLaunchConfig = requireOptionalNativeModule('VerifyLaunchConfig');
diff --git a/integration/templates/expo-native/modules/verify-launch-config/ios/VerifyLaunchConfig.podspec b/integration/templates/expo-native/modules/verify-launch-config/ios/VerifyLaunchConfig.podspec
new file mode 100644
index 00000000000..0974bd2db66
--- /dev/null
+++ b/integration/templates/expo-native/modules/verify-launch-config/ios/VerifyLaunchConfig.podspec
@@ -0,0 +1,18 @@
+Pod::Spec.new do |s|
+ s.name = 'VerifyLaunchConfig'
+ s.version = '1.0.0'
+ s.summary = 'Reads verify launch inputs for the Expo native fixture'
+ s.author = ''
+ s.homepage = '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/clerk/javascript'
+ s.platforms = { :ios => '15.1' }
+ s.source = { git: '' }
+ s.static_framework = true
+
+ s.dependency 'ExpoModulesCore'
+
+ s.pod_target_xcconfig = {
+ 'DEFINES_MODULE' => 'YES',
+ }
+
+ s.source_files = "**/*.swift"
+end
diff --git a/integration/templates/expo-native/modules/verify-launch-config/ios/VerifyLaunchConfigModule.swift b/integration/templates/expo-native/modules/verify-launch-config/ios/VerifyLaunchConfigModule.swift
new file mode 100644
index 00000000000..88f832b9638
--- /dev/null
+++ b/integration/templates/expo-native/modules/verify-launch-config/ios/VerifyLaunchConfigModule.swift
@@ -0,0 +1,48 @@
+import ExpoModulesCore
+import Security
+
+public class VerifyLaunchConfigModule: Module {
+ private static let storageScopeKey = "VerifyLaunchConfig.storageScope"
+ private static let clerkDefaultsKeys = [
+ "authStartIdentifier",
+ "authStartPhoneNumber",
+ "authStartPhoneNumberFieldIsActive",
+ "clerk_last_used_identifier_type",
+ ]
+
+ public func definition() -> ModuleDefinition {
+ Name("VerifyLaunchConfig")
+
+ OnCreate {
+ if Self.launchInputs()["verifyLaunchId"] != nil {
+ Self.keepPasswordAutoFillOffNativeClerkViews()
+ }
+ }
+
+ Function("readLaunchInputs") { () -> [String: String] in
+ Self.launchInputs()
+ }
+
+ Function("applyStorageScope") { (scope: String) in
+ let defaults = UserDefaults.standard
+ guard defaults.string(forKey: Self.storageScopeKey) != scope else {
+ return
+ }
+
+ SecItemDelete([kSecClass: kSecClassGenericPassword] as CFDictionary)
+ Self.clerkDefaultsKeys.forEach(defaults.removeObject(forKey:))
+ defaults.set(scope, forKey: Self.storageScopeKey)
+ }
+ }
+
+ private static func launchInputs() -> [String: String] {
+ UserDefaults.standard
+ .volatileDomain(forName: UserDefaults.argumentDomain)
+ .filter { $0.key.hasPrefix("verify") }
+ .compactMapValues { $0 as? String }
+ }
+
+ private static func keepPasswordAutoFillOffNativeClerkViews() {
+ setenv("CLERK_E2E_MODE", "1", 1)
+ }
+}
diff --git a/integration/templates/expo-native/package.sdk-57.json b/integration/templates/expo-native/package.sdk-57.json
index 9523431c830..070c763e076 100644
--- a/integration/templates/expo-native/package.sdk-57.json
+++ b/integration/templates/expo-native/package.sdk-57.json
@@ -10,7 +10,8 @@
"prebuild:ios": "expo prebuild --clean --platform ios"
},
"dependencies": {
- "expo": "~57.0.1",
+ "expo": "~57.0.23",
+ "expo-build-properties": "~57.0.22",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-native": "0.86.0"
diff --git a/integration/templates/expo-native/screens/AuthLogo.tsx b/integration/templates/expo-native/screens/AuthLogo.tsx
new file mode 100644
index 00000000000..bc5c3781f9e
--- /dev/null
+++ b/integration/templates/expo-native/screens/AuthLogo.tsx
@@ -0,0 +1,24 @@
+import { type ReactElement } from 'react';
+import { StyleSheet, Text, View } from 'react-native';
+
+export function AuthLogo(): ReactElement {
+ return (
+
+ E2E Custom Logo
+
+ );
+}
+
+const styles = StyleSheet.create({
+ logo: {
+ backgroundColor: '#6C47FF',
+ borderRadius: 8,
+ paddingHorizontal: 16,
+ paddingVertical: 10,
+ },
+ logoText: {
+ color: '#FFFFFF',
+ fontSize: 16,
+ fontWeight: '600',
+ },
+});
diff --git a/integration/templates/expo-native/screens/CustomSignIn.tsx b/integration/templates/expo-native/screens/CustomSignIn.tsx
new file mode 100644
index 00000000000..06d4b1b59d2
--- /dev/null
+++ b/integration/templates/expo-native/screens/CustomSignIn.tsx
@@ -0,0 +1,75 @@
+import { useSignIn } from '@clerk/expo';
+import { type ReactElement, useState } from 'react';
+import { Button, Text, TextInput, View } from 'react-native';
+
+import { styles } from './styles';
+
+export function CustomSignIn(): ReactElement {
+ const { signIn, fetchStatus } = useSignIn();
+ const [emailAddress, setEmailAddress] = useState('');
+ const [code, setCode] = useState('');
+ const [codeSent, setCodeSent] = useState(false);
+ const [error, setError] = useState(null);
+ const busy = fetchStatus === 'fetching';
+
+ const sendCode = async (): Promise => {
+ const { error } = await signIn.emailCode.sendCode({ emailAddress });
+ setError(error?.message ?? null);
+ setCodeSent(!error);
+ };
+
+ const verifyCode = async (): Promise => {
+ const { error } = await signIn.emailCode.verifyCode({ code });
+ if (error || signIn.status !== 'complete') {
+ setError(error?.message ?? `Sign-in is ${signIn.status}`);
+ return;
+ }
+ const finalized = await signIn.finalize();
+ setError(finalized.error?.message ?? null);
+ };
+
+ return (
+
+ Custom sign-in
+ {codeSent ? (
+ <>
+
+
+ );
+}
diff --git a/integration/templates/expo-native/screens/CustomSignUp.tsx b/integration/templates/expo-native/screens/CustomSignUp.tsx
new file mode 100644
index 00000000000..61240a7342a
--- /dev/null
+++ b/integration/templates/expo-native/screens/CustomSignUp.tsx
@@ -0,0 +1,86 @@
+import { useSignUp } from '@clerk/expo';
+import { type ReactElement, useState } from 'react';
+import { Button, Text, TextInput, View } from 'react-native';
+
+import { styles } from './styles';
+
+export function CustomSignUp(): ReactElement {
+ const { signUp, fetchStatus } = useSignUp();
+ const [emailAddress, setEmailAddress] = useState('');
+ const [password, setPassword] = useState('');
+ const [code, setCode] = useState('');
+ const [codeSent, setCodeSent] = useState(false);
+ const [error, setError] = useState(null);
+ const busy = fetchStatus === 'fetching';
+
+ const sendCode = async (): Promise => {
+ const created = await signUp.create({ emailAddress, password });
+ const sent = created.error ? created : await signUp.verifications.sendEmailCode();
+ setError(sent.error?.message ?? null);
+ setCodeSent(!sent.error);
+ };
+
+ const verifyCode = async (): Promise => {
+ const { error } = await signUp.verifications.verifyEmailCode({ code });
+ if (error || signUp.status !== 'complete') {
+ setError(error?.message ?? `Sign-up is ${signUp.status}`);
+ return;
+ }
+ const finalized = await signUp.finalize();
+ setError(finalized.error?.message ?? null);
+ };
+
+ return (
+
+ Custom sign-up
+ {codeSent ? (
+ <>
+
+ void verifyCode()}
+ />
+ >
+ ) : (
+ <>
+
+
+ void sendCode()}
+ />
+ >
+ )}
+ {error && {error}}
+
+ );
+}
diff --git a/integration/templates/expo-native/screens/EmbeddedProfile.tsx b/integration/templates/expo-native/screens/EmbeddedProfile.tsx
new file mode 100644
index 00000000000..1c7a2fc7579
--- /dev/null
+++ b/integration/templates/expo-native/screens/EmbeddedProfile.tsx
@@ -0,0 +1,38 @@
+import { UserProfileView } from '@clerk/expo/native';
+import { type ReactElement } from 'react';
+import { StyleSheet, Text, View } from 'react-native';
+
+export function EmbeddedProfile({ onHostBack }: { onHostBack: () => void }): ReactElement {
+ return (
+
+ Rehosted RN body
+
+ ),
+ },
+ ]}
+ isDismissible={false}
+ onHostBack={onHostBack}
+ />
+ );
+}
+
+const styles = StyleSheet.create({
+ customPage: {
+ alignItems: 'center',
+ flex: 1,
+ justifyContent: 'center',
+ padding: 24,
+ },
+ customPageText: {
+ fontSize: 16,
+ fontWeight: '600',
+ },
+});
diff --git a/integration/templates/expo-native/screens/Home.tsx b/integration/templates/expo-native/screens/Home.tsx
new file mode 100644
index 00000000000..e875427159b
--- /dev/null
+++ b/integration/templates/expo-native/screens/Home.tsx
@@ -0,0 +1,143 @@
+import { useAuth, useSession, useUser } from '@clerk/expo';
+import { AuthView, type AuthViewMode, UserButton } from '@clerk/expo/native';
+import { type ReactElement, useState } from 'react';
+import { Button, Modal, StyleSheet, Text, View } from 'react-native';
+
+import { AuthLogo } from './AuthLogo';
+import { type Destination, homeLinksFor } from './destinations';
+
+type HomeProps = { authMode: AuthViewMode; onOpen: (destination: Destination) => void };
+
+export function Home({ authMode, onOpen }: HomeProps): ReactElement {
+ const { isSignedIn, signOut } = useAuth();
+ const { user } = useUser();
+ const { session } = useSession();
+ const [authSheet, setAuthSheet] = useState<'closed' | 'plain' | 'withLogo'>('closed');
+ const closeAuth = (): void => setAuthSheet('closed');
+
+ const pending = session?.status === 'pending';
+ const account = pending ? null : user;
+ const activeSession = pending ? null : session;
+ const emailAddress = account?.primaryEmailAddress?.emailAddress;
+
+ return (
+
+ {isSignedIn && }
+ {(account || activeSession) && (
+
+ {account && (
+
+ {emailAddress ? `Signed in as ${emailAddress}` : 'Signed in'}
+
+ )}
+ {account && (
+
+ User ID
+
+ {account.id}
+
+
+ )}
+ {activeSession && (
+
+ Session ID
+
+ {activeSession.id}
+
+
+ )}
+
+ )}
+ {isSignedIn ? (
+ void signOut()}
+ />
+ ) : (
+ <>
+
+ Signed out
+
+ setAuthSheet('plain')}
+ />
+ >
+ )}
+
+ {!isSignedIn && (
+ setAuthSheet('withLogo')}
+ />
+ )}
+ {homeLinksFor(Boolean(isSignedIn)).map(link => (
+ onOpen(link.opens)}
+ />
+ ))}
+
+
+
+ : undefined}
+ onDismiss={closeAuth}
+ />
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ detail: {
+ flexDirection: 'row',
+ gap: 4,
+ },
+ detailText: {
+ color: '#6B6B76',
+ fontSize: 13,
+ },
+ heading: {
+ fontSize: 17,
+ textAlign: 'center',
+ },
+ home: {
+ alignItems: 'center',
+ flex: 1,
+ gap: 24,
+ justifyContent: 'center',
+ padding: 24,
+ },
+ links: {
+ alignItems: 'center',
+ gap: 4,
+ },
+ summary: {
+ alignItems: 'center',
+ gap: 4,
+ },
+});
diff --git a/integration/templates/expo-native/screens/NativeModules.tsx b/integration/templates/expo-native/screens/NativeModules.tsx
new file mode 100644
index 00000000000..08d4cd86547
--- /dev/null
+++ b/integration/templates/expo-native/screens/NativeModules.tsx
@@ -0,0 +1,16 @@
+import { type ReactElement } from 'react';
+import { Text, View } from 'react-native';
+
+import { BiometricAvailabilityButton } from '../components/BiometricAvailabilityButton';
+import { GoogleSignInButton } from '../components/GoogleSignInButton';
+import { styles } from './styles';
+
+export function NativeModules(): ReactElement {
+ return (
+
+ Native modules
+
+
+
+ );
+}
diff --git a/integration/templates/expo-native/screens/TokenCache.tsx b/integration/templates/expo-native/screens/TokenCache.tsx
new file mode 100644
index 00000000000..05f3321cb34
--- /dev/null
+++ b/integration/templates/expo-native/screens/TokenCache.tsx
@@ -0,0 +1,31 @@
+import { useUser } from '@clerk/expo';
+import { tokenCache } from '@clerk/expo/token-cache';
+import { type ReactElement, useEffect, useState } from 'react';
+import { Text, View } from 'react-native';
+
+import { styles } from './styles';
+
+const clientTokenKey = '__clerk_client_jwt';
+
+export async function readClientTokenKept(): Promise {
+ return Boolean(await tokenCache?.getToken(clientTokenKey));
+}
+
+export function TokenCache({ keptAtLaunch }: { keptAtLaunch: Promise }): ReactElement {
+ const { isLoaded, user } = useUser();
+ const [kept, setKept] = useState(null);
+
+ useEffect(() => {
+ void keptAtLaunch.then(setKept);
+ }, [keptAtLaunch]);
+
+ return (
+
+ Token cache
+
+ {`client token kept from the last launch: ${kept === null ? 'checking' : kept ? 'yes' : 'no'}`}
+
+ {`user: ${isLoaded ? (user?.id ?? 'none') : 'loading'}`}
+
+ );
+}
diff --git a/integration/templates/expo-native/screens/destinations.ts b/integration/templates/expo-native/screens/destinations.ts
new file mode 100644
index 00000000000..08861ab9fa5
--- /dev/null
+++ b/integration/templates/expo-native/screens/destinations.ts
@@ -0,0 +1,35 @@
+export type Destination =
+ | 'auth'
+ | 'nativeAuth'
+ | 'customSignIn'
+ | 'customSignUp'
+ | 'nativeModules'
+ | 'embeddedProfile'
+ | 'tokenCache';
+
+type HomeLink = {
+ opens: Destination;
+ label: string;
+ testID: string;
+ shownWhen: 'signedOut' | 'signedIn' | 'always';
+};
+
+const homeLinks: readonly HomeLink[] = [
+ { opens: 'auth', label: 'Sign in full screen', testID: 'e2e.auth.signInFullScreen', shownWhen: 'signedOut' },
+ {
+ opens: 'nativeAuth',
+ label: 'Sign in full screen with a close button',
+ testID: 'e2e.home.nativeAuth',
+ shownWhen: 'signedOut',
+ },
+ { opens: 'customSignIn', label: 'Custom sign-in', testID: 'e2e.home.customSignIn', shownWhen: 'signedOut' },
+ { opens: 'customSignUp', label: 'Custom sign-up', testID: 'e2e.home.customSignUp', shownWhen: 'signedOut' },
+ { opens: 'nativeModules', label: 'Native modules', testID: 'e2e.home.nativeModules', shownWhen: 'signedOut' },
+ { opens: 'embeddedProfile', label: 'Embedded profile', testID: 'e2e.home.embeddedProfile', shownWhen: 'signedIn' },
+ { opens: 'tokenCache', label: 'Token cache', testID: 'e2e.home.tokenCache', shownWhen: 'always' },
+];
+
+export function homeLinksFor(isSignedIn: boolean): readonly HomeLink[] {
+ const state = isSignedIn ? 'signedIn' : 'signedOut';
+ return homeLinks.filter(link => link.shownWhen === 'always' || link.shownWhen === state);
+}
diff --git a/integration/templates/expo-native/screens/styles.ts b/integration/templates/expo-native/screens/styles.ts
new file mode 100644
index 00000000000..99335480ffe
--- /dev/null
+++ b/integration/templates/expo-native/screens/styles.ts
@@ -0,0 +1,22 @@
+import { StyleSheet } from 'react-native';
+
+export const styles = StyleSheet.create({
+ form: {
+ flex: 1,
+ gap: 12,
+ justifyContent: 'center',
+ padding: 24,
+ },
+ input: {
+ borderColor: '#C9C9D1',
+ borderRadius: 8,
+ borderWidth: 1,
+ fontSize: 16,
+ paddingHorizontal: 12,
+ paddingVertical: 10,
+ },
+ title: {
+ fontSize: 20,
+ fontWeight: '600',
+ },
+});
diff --git a/integration/templates/expo-native/verify/VerifyHost.tsx b/integration/templates/expo-native/verify/VerifyHost.tsx
new file mode 100644
index 00000000000..733521d83a8
--- /dev/null
+++ b/integration/templates/expo-native/verify/VerifyHost.tsx
@@ -0,0 +1,283 @@
+import {
+ ClerkProvider,
+ isClerkAPIResponseError,
+ useAuth,
+ useClerk,
+ useSession,
+ useSignIn,
+ useSignUp,
+ useUser,
+} from '@clerk/expo';
+import { AuthView, useAuthViewState } from '@clerk/expo/native';
+import { tokenCache } from '@clerk/expo/token-cache';
+import { type ReactElement, useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react';
+import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
+
+import { CustomSignIn } from '../screens/CustomSignIn';
+import { CustomSignUp } from '../screens/CustomSignUp';
+import { type Destination, homeLinksFor } from '../screens/destinations';
+import { EmbeddedProfile } from '../screens/EmbeddedProfile';
+import { Home } from '../screens/Home';
+import { NativeModules } from '../screens/NativeModules';
+import { readClientTokenKept, TokenCache } from '../screens/TokenCache';
+import { publishableKeyFailure, type VerifyFailure, type VerifyLaunch } from './launch';
+import { type VerifyState, verifyStateJson, type VerifyTicket } from './state';
+
+type HostProps = { launch: VerifyLaunch };
+type RoutedHostProps = HostProps & { clientTokenKeptAtLaunch: Promise };
+
+export function VerifyHost({ launch }: HostProps): ReactElement {
+ const [clientTokenKeptAtLaunch] = useState(readClientTokenKept);
+ const failure = publishableKeyFailure(launch.publishableKey);
+ if (failure) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
+
+export function logRequests(): void {
+ const send = globalThis.fetch;
+ globalThis.fetch = async (input, init) => {
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
+ const method = init?.method ?? (input instanceof Request ? input.method : 'GET');
+ const line = `[verify:network] ${method} ${url.split('?')[0]}`;
+ try {
+ const response = await send(input, init);
+ console.log(`${line} ${response.status}`);
+ return response;
+ } catch (error) {
+ console.log(`${line} failed`);
+ throw error;
+ }
+ };
+}
+
+function failureFrom(error: unknown, fallbackCode: string): VerifyFailure {
+ if (isClerkAPIResponseError(error) && error.errors[0]) {
+ const [first] = error.errors;
+ return { code: first.code, message: first.longMessage ?? first.message };
+ }
+ return { code: fallbackCode, message: error instanceof Error ? error.message : String(error) };
+}
+
+function useClerkStatus(): ReturnType['status'] {
+ const clerk = useClerk();
+ const subscribe = useCallback(
+ (onChange: () => void) => {
+ clerk.on('status', onChange);
+ return () => clerk.off('status', onChange);
+ },
+ [clerk],
+ );
+ return useSyncExternalStore(subscribe, () => clerk.status);
+}
+
+function useVerifyLog(state: VerifyState): void {
+ const json = verifyStateJson(state);
+
+ useEffect(() => {
+ console.log(`[verify] ${json}`);
+ }, [json]);
+}
+
+function RoutedHost({ launch, clientTokenKeptAtLaunch }: RoutedHostProps): ReactElement {
+ const status = useClerkStatus();
+ const { isLoaded, isSignedIn, orgId } = useAuth();
+ const { user } = useUser();
+ const { session } = useSession();
+ const { signIn } = useSignIn();
+ const { signUp } = useSignUp();
+ const authView = useAuthViewState();
+ const [opened, setOpened] = useState(null);
+ const goHome = useCallback(() => setOpened(null), []);
+ const [ticket, setTicket] = useState(launch.signInTicket ? 'pending' : 'none');
+ const [failure, setFailure] = useState(null);
+ const ticketStarted = useRef(false);
+
+ useEffect(() => {
+ if (status !== 'error') {
+ return;
+ }
+ setFailure({ code: 'environment_load_failed', message: 'Clerk failed to load the environment.' });
+ setTicket(current => (current === 'pending' ? 'failed' : current));
+ }, [status]);
+
+ useEffect(() => {
+ const signInTicket = launch.signInTicket;
+ if (!signInTicket || !isLoaded || ticketStarted.current) {
+ return;
+ }
+ ticketStarted.current = true;
+
+ const signInWithTicket = async (): Promise => {
+ const created = await signIn.ticket({ ticket: signInTicket });
+ if (created.error) {
+ throw created.error;
+ }
+ const finalized = await signIn.finalize();
+ if (finalized.error) {
+ throw finalized.error;
+ }
+ };
+
+ signInWithTicket().then(
+ () => setTicket('succeeded'),
+ (error: unknown) => {
+ setFailure(failureFrom(error, 'ticket_sign_in_failed'));
+ setTicket('failed');
+ },
+ );
+ }, [isLoaded, launch.signInTicket, signIn]);
+
+ const showsAuthView = opened === 'auth' || opened === 'nativeAuth';
+ const finished = showsAuthView
+ ? authView.isAuthFlowComplete
+ : !homeLinksFor(Boolean(isSignedIn)).some(link => link.opens === opened);
+ const left = opened !== null && finished;
+
+ useEffect(() => {
+ if (left) {
+ goHome();
+ }
+ }, [goHome, left]);
+
+ const screen = ((): Destination | 'home' | 'loading' => {
+ if (!isLoaded || ticket === 'pending') {
+ return 'loading';
+ }
+ if (opened === null || left) {
+ return 'home';
+ }
+ return showsAuthView && !authView.isLoaded ? 'loading' : opened;
+ })();
+
+ useVerifyLog({
+ runId: launch.runId,
+ launchId: launch.launchId,
+ screen: failure ? 'error' : screen === 'loading' ? 'launching' : screen,
+ environmentLoaded: isLoaded,
+ signedIn: Boolean(user),
+ userId: user?.id ?? null,
+ sessionId: session?.id ?? null,
+ sessionStatus: session?.status === 'active' || session?.status === 'pending' ? session.status : null,
+ pendingTasks: session?.tasks?.map(task => task.key) ?? [],
+ orgId: orgId ?? null,
+ signInStatus: signIn.id ? signIn.status : null,
+ signUpStatus: signUp.id ? signUp.status : null,
+ ticket,
+ lastError: failure,
+ extra: { authViewLoaded: authView.isLoaded, authFlowComplete: authView.isAuthFlowComplete },
+ });
+
+ if (failure) {
+ return ;
+ }
+
+ switch (screen) {
+ case 'loading':
+ return ;
+ case 'home':
+ return (
+
+ );
+ case 'auth':
+ return (
+
+ );
+ case 'nativeAuth':
+ return (
+
+ );
+ case 'customSignIn':
+ return ;
+ case 'customSignUp':
+ return ;
+ case 'tokenCache':
+ return ;
+ case 'embeddedProfile':
+ return ;
+ case 'nativeModules':
+ return ;
+ }
+}
+
+function ConfigurationFailure({ launch, failure }: HostProps & { failure: VerifyFailure }): ReactElement {
+ useVerifyLog({
+ runId: launch.runId,
+ launchId: launch.launchId,
+ screen: 'error',
+ environmentLoaded: false,
+ signedIn: false,
+ userId: null,
+ sessionId: null,
+ sessionStatus: null,
+ pendingTasks: [],
+ orgId: null,
+ signInStatus: null,
+ signUpStatus: null,
+ ticket: launch.signInTicket ? 'failed' : 'none',
+ lastError: failure,
+ extra: {},
+ });
+
+ return ;
+}
+
+function LaunchError({ failure }: { failure: VerifyFailure }): ReactElement {
+ return (
+
+
+ Something went wrong
+ {`\n${failure.message}`}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ centered: {
+ alignItems: 'center',
+ flex: 1,
+ justifyContent: 'center',
+ padding: 24,
+ },
+ error: {
+ fontSize: 16,
+ lineHeight: 24,
+ textAlign: 'center',
+ },
+ errorTitle: {
+ fontWeight: '600',
+ },
+ fill: {
+ flex: 1,
+ },
+});
diff --git a/integration/templates/expo-native/verify/launch.ts b/integration/templates/expo-native/verify/launch.ts
new file mode 100644
index 00000000000..62eee3e87b0
--- /dev/null
+++ b/integration/templates/expo-native/verify/launch.ts
@@ -0,0 +1,69 @@
+import { VerifyLaunchConfig } from '../modules/verify-launch-config';
+
+const authModes = ['signIn', 'signUp', 'signInOrUp'] as const;
+
+type AuthMode = (typeof authModes)[number];
+export type VerifyFailure = { code: string; message: string };
+
+export type VerifyLaunch = {
+ publishableKey: string;
+ runId: string | null;
+ launchId: string | null;
+ authMode: AuthMode;
+ signInTicket: string | null;
+ debugLogging: boolean;
+};
+
+function isOneOf(values: readonly T[], value: string | undefined): value is T {
+ return values.includes(value as T);
+}
+
+function parseVerifyLaunch(
+ inputs: Readonly>,
+ fallbackPublishableKey: string | undefined,
+): VerifyLaunch | null {
+ if (Object.keys(inputs).length === 0) {
+ return null;
+ }
+
+ return {
+ publishableKey: inputs.verifyPublishableKey ?? fallbackPublishableKey ?? '',
+ runId: inputs.verifyRunId ?? null,
+ launchId: inputs.verifyLaunchId ?? null,
+ authMode: isOneOf(authModes, inputs.verifyAuthMode) ? inputs.verifyAuthMode : 'signInOrUp',
+ signInTicket: inputs.verifySignInTicket ?? null,
+ debugLogging: inputs.verifyLogLevel === 'debug',
+ };
+}
+
+export function publishableKeyFailure(publishableKey: string): VerifyFailure | null {
+ const [prefix, environment, encoded, ...rest] = publishableKey.split('_');
+ const wellFormed = prefix === 'pk' && (environment === 'test' || environment === 'live') && encoded && !rest.length;
+ const decoded = wellFormed ? decodeBase64(encoded) : '';
+ const host = decoded.slice(0, -1);
+
+ if (decoded.endsWith('$') && !host.includes('$') && host.includes('.')) {
+ return null;
+ }
+
+ return {
+ code: 'invalid_publishable_key',
+ message: 'The publishable key is missing or is not a pk_test_ or pk_live_ key.',
+ };
+}
+
+function decodeBase64(value: string): string {
+ try {
+ return atob(value);
+ } catch {
+ return '';
+ }
+}
+
+export function readVerifyLaunch(): VerifyLaunch | null {
+ const inputs = VerifyLaunchConfig?.readLaunchInputs() ?? {};
+ if (inputs.verifyStorageScope) {
+ VerifyLaunchConfig?.applyStorageScope(inputs.verifyStorageScope);
+ }
+ return parseVerifyLaunch(inputs, process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY);
+}
diff --git a/integration/templates/expo-native/verify/state.ts b/integration/templates/expo-native/verify/state.ts
new file mode 100644
index 00000000000..019e69a36aa
--- /dev/null
+++ b/integration/templates/expo-native/verify/state.ts
@@ -0,0 +1,25 @@
+import type { VerifyFailure } from './launch';
+
+export type VerifyTicket = 'none' | 'pending' | 'succeeded' | 'failed';
+
+export type VerifyState = {
+ runId: string | null;
+ launchId: string | null;
+ screen: string;
+ environmentLoaded: boolean;
+ signedIn: boolean;
+ userId: string | null;
+ sessionId: string | null;
+ sessionStatus: 'active' | 'pending' | null;
+ pendingTasks: string[];
+ orgId: string | null;
+ signInStatus: string | null;
+ signUpStatus: string | null;
+ ticket: VerifyTicket;
+ lastError: VerifyFailure | null;
+ extra: Record;
+};
+
+export function verifyStateJson(state: VerifyState): string {
+ return JSON.stringify(state);
+}
diff --git a/integration/tests/expo-native/native-modules.e2e.ts b/integration/tests/expo-native/native-modules.e2e.ts
index 52d344989be..35a38221c0c 100644
--- a/integration/tests/expo-native/native-modules.e2e.ts
+++ b/integration/tests/expo-native/native-modules.e2e.ts
@@ -5,11 +5,6 @@ import { test } from './fixtures.ts';
test.describe('@clerk/expo native modules', () => {
test.beforeEach(({ po }) => po.app.reset());
- test('useSignInWithGoogle surfaces the missing-credentials error', async ({ screen }) => {
- await screen.getByTestId('google-sign-in-button').tap();
- await expect(screen.getByText(/Google Sign-In credentials not found/)).toBeVisible({ timeout: 15_000 });
- });
-
test('useBiometricCredentials reaches the expo-biometrics native module', async ({ screen }) => {
await screen.getByTestId('biometric-availability-button').tap();
await expect(screen.getByText(/^biometric availability: /)).toBeVisible({ timeout: 15_000 });