A native mobile sign-in screen on a phone beside a relaxed red Maine Coon cat on a sunlit desk.

Clerk for Expo: Native Mobile Auth Without the Headache

The Orange Cat
The Orange Cat

If you have ever tried to build authentication into a mobile app from scratch, you already know the trap. It looks like a weekend project and turns into a month. You need secure token storage in the platform keychain, refresh-token rotation, session persistence across app restarts, email and SMS verification codes, password reset flows, social OAuth that round-trips through the system browser and deep-links back into your app, biometric unlock, multi-factor auth, and a user-profile screen on top of all of it. In React Native this is genuinely harder than on the web, because there are no cookies, redirects have to bounce through the OS, and tokens must live in the iOS Keychain or Android Keystore.

@clerk/clerk-expo is Clerk's official SDK for Expo and React Native, and it hands you all of that as a managed service behind a small, friendly API. Clerk is a complete authentication-and-user-management platform: it hosts the user database, runs sessions, sends the verification emails and SMS, manages social connections, MFA, and organizations, and exposes everything through React hooks, declarative control components, and prebuilt native UI. You drop in a provider, wire up a secure token cache, and you are signing users in.

What Makes It Worth Reaching For

The headline feature, new in the Expo SDK 3.x line that landed in early 2026, is prebuilt native UI components. Unlike the webview-based login screens you get from many auth providers, Clerk's <AuthView /> renders with SwiftUI on iOS and Jetpack Compose on Android. It feels like a first-party system screen, not an embedded web page.

Beyond the native components, here is what you get out of the box:

  • Hooks for fully custom UI: useAuth, useUser, useSignIn, useSignUp, useClerk, useSession, useOrganization, useOrganizationList, and useSSO. This is the stable, battle-tested path.
  • Native social sign-in: useSignInWithGoogle() uses the system credential picker on iOS and Credential Manager (one-tap, passkey-ready) on Android, with no webview. useSignInWithApple() does native Apple sign-in. Browser-based OAuth via useSSO() still works in Expo Go.
  • Passwordless and OTP: email-code and SMS-code flows ready to go.
  • MFA: TOTP authenticator apps, SMS, and backup codes.
  • Passkeys, biometrics, and organizations: WebAuthn via @clerk/expo/passkeys, Face ID / Touch ID re-unlock via @clerk/expo/local-credentials, and full multi-tenant B2B orgs with roles and invitations.
  • Control components: <SignedIn>, <SignedOut>, and <Show> for declarative gating.
  • Backend tokens: getToken() mints short-lived JWTs, optionally with custom templates, to authenticate your own API or services like Supabase and Convex.

The free Hobby tier covers up to 50,000 Monthly Retained Users, which is generous enough that most early-stage apps never see a bill before they have real traction.

Getting It Installed

There are two paths, depending on whether you want the native components. The native approach needs a custom dev build (expo-dev-client) and will not run in Expo Go. The JavaScript-only approach runs anywhere.

# Native components approach (recommended, needs a dev build)
npx expo install @clerk/expo expo-secure-store expo-auth-session expo-web-browser expo-dev-client

# JavaScript-only approach (works in Expo Go)
npx expo install @clerk/expo expo-secure-store

If you prefer yarn:

yarn add @clerk/clerk-expo expo-secure-store expo-auth-session expo-web-browser expo-dev-client

Add your publishable key to an .env file. The EXPO_PUBLIC_ prefix is mandatory so the value is inlined into the client bundle. Always use the publishable key here, never the secret one.

EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_xxx

Finally, register the plugins in app.json and rebuild. Both expo-secure-store and @clerk/expo need to be present.

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

Wiring Up the Provider

Everything starts with <ClerkProvider> at the root of your app. The single most important detail here is the tokenCache. Pass it, and Clerk encrypts session tokens in the iOS Keychain or Android Keystore so sessions survive cold starts. Forget it, and your users get logged out every single time they relaunch the app.

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

const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!

if (!publishableKey) {
  throw new Error('Add your Clerk Publishable Key to the .env file')
}

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

The tokenCache from @clerk/expo/token-cache is backed by expo-secure-store, which is why those JWTs never end up sitting in plaintext AsyncStorage. Treat this as non-negotiable setup, not an optional extra.

Knowing Who Is Signed In

With the provider in place, reading auth state anywhere in your tree is a one-liner. useAuth gives you the session lifecycle, and useUser gives you the profile.

import { useAuth, useUser } from '@clerk/expo'
import { Text } from 'react-native'

function Profile() {
  const { isLoaded, isSignedIn, signOut, getToken } = useAuth()
  const { user } = useUser()

  if (!isLoaded) return null
  if (!isSignedIn) return <Text>Sign in</Text>

  return <Text>Hello {user?.firstName}</Text>
}

Always check isLoaded before branching on isSignedIn. During the first render Clerk is still hydrating the cached session, and reading auth state too early leads to a flash of the wrong screen.

Building a Custom Sign-In Screen

When you want full control over the look and feel, useSignIn drives the flow. You create an attempt, and when its status comes back complete, you activate the session and route the user onward.

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

function SignInScreen() {
  const { signIn, setActive, isLoaded } = useSignIn()
  const router = useRouter()

  const onSignIn = async (email: string, password: string) => {
    if (!isLoaded) return

    const attempt = await signIn.create({ identifier: email, password })

    if (attempt.status === 'complete') {
      await setActive({ session: attempt.createdSessionId })
      router.replace('/')
    }
  }
}

The status field is the heart of every Clerk flow. A complete status means you are done, but other statuses signal that more steps are needed, such as an MFA challenge or email verification. Branching on it is how you build multi-step screens.

Sign-Up With Email Verification

Sign-up follows the same create-then-attempt rhythm, with a verification step in the middle. You create the user, ask Clerk to send a code, collect that code from the user, then attempt the verification.

import { useSignUp } from '@clerk/expo'

const { signUp, setActive, isLoaded } = useSignUp()

await signUp.create({ emailAddress, password })
await signUp.prepareEmailAddressVerification({ strategy: 'email_code' })

// ...user enters the code from their inbox...

const result = await signUp.attemptEmailAddressVerification({ code })

if (result.status === 'complete') {
  await setActive({ session: result.createdSessionId })
}

Clerk sends the email, manages the code expiry, and tracks the verification state for you. All you own is the input field and the call.

Going Native: Zero-UI Auth Screens

Here is where the SDK earns its keep. The <AuthView /> component renders a complete, polished sign-in and sign-up interface natively, handling email/password, passwordless codes, MFA, password recovery, and social buttons internally. You write no auth UI at all.

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

export default function Auth() {
  return <AuthView />
}

For social, the native hooks give you system-level pickers instead of a browser round-trip:

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

function GoogleButton() {
  const { signIn } = useSignInWithGoogle()
  return <Button title="Continue with Google" onPress={() => signIn()} />
}

Two things to remember with the native path. First, these components require a dev build and will not run in Expo Go, and the Native API must be enabled in your Clerk Dashboard with your iOS/Android apps registered. Second, the native components are still labeled beta, so for production-critical flows the hooks-and-control-components path remains the safe, stable bet. When you do use native components, the docs recommend useAuth({ treatPendingAsSignedOut: false }) so that partially-completed sessions, like one mid-MFA, are handled correctly rather than being treated as signed out.

Authenticating Your Own Backend

Clerk does not just guard the client. The getToken() function from useAuth mints a short-lived JWT you attach to requests against your own API or a third-party service. With JWT templates you can shape the token's claims, which is exactly how the popular Clerk-plus-Supabase combination works: Clerk owns identity, Supabase owns data, and the JWT bridges them.

import { useAuth } from '@clerk/expo'

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

  const fetchData = async () => {
    const token = await getToken()
    const res = await fetch('https://api.example.com/me', {
      headers: { Authorization: `Bearer ${token}` },
    })
    return res.json()
  }

  return { fetchData }
}

Because the tokens are short-lived, you call getToken() fresh per request rather than caching it yourself. Clerk handles refresh transparently.

A Few Things to Watch For

A couple of gotchas trip up almost everyone. The version numbers in the docs and on npm do not match: the docs talk about "Expo SDK 3.x" while npm shows 2.19.x. These are different version trains, so trust the docs as your feature label and npm for the install. The native components, as mentioned, need a dev build, so do not follow the native quickstart inside Expo Go and expect it to work. And remember that your user records live on Clerk's servers, which means moving off later involves exporting users and rebuilding auth elsewhere.

That last point is the real trade-off. Clerk is a managed service, and you are trading data ownership for a genuinely excellent developer experience. If you must self-host and own every byte of user data, an open-source library like Better Auth is the move. If you are already all-in on Supabase or Firebase, their bundled auth may be the path of least resistance. And for heavy enterprise SSO and compliance, Auth0 is purpose-built for that world.

The Verdict

For the vast majority of Expo apps, Clerk hits a sweet spot that is hard to beat. You get production-grade mobile auth in an afternoon instead of a sprint, native non-webview sign-in UI that feels like it shipped with the OS, and MFA, passwordless, passkeys, biometrics, and organizations without stitching together five separate services. The 50,000 free Monthly Retained User tier comfortably covers most launches, and the developer experience, from hooks to control components to the dashboard and docs, is widely regarded as best-in-class.

If you want auth to be the boring, solved part of your mobile app, @clerk/clerk-expo is about as close as the ecosystem gets.