All skills
clerk avatar

/clerk-expo

@3d0c898 official
by clerkclerk/skills83 stars
5

Add Clerk authentication to Expo and React Native apps using @clerk/expo. Use for Expo setup, prebuilt native components (AuthView, UserButton), custom sign-in/sign-up flows (email, password, SMS/phone OTP, MFA), OAuth/SSO, native Google/Apple sign-in, Expo Router protected routes, biometrics, and push notifications. Do not use for native Swift/iOS, native Android/Kotlin, or web-only framework projects.

Use this Skill: https://skilld.dev/gh/clerk/skills/clerk-expo

This session only. Nothing lands on disk.

referencescustom-flows.md

≈2.4k tokens on demand. Your agent reads this file only when SKILL.md points to it.

Custom flows (@clerk/expo hooks)

Build your own auth UI with useSignIn() / useSignUp(). Works everywhere including Expo Go and web. Use when the developer wants their own UI, needs Expo Go, or asked for a specific strategy like SMS.

Canonical docs — each has an Expo tab with a full working example; fetch the relevant one to re-verify if the installed SDK is newer than 3.6.x:

The current API (v3.4+) — not the legacy one

useSignIn() and useSignUp() return { signIn, errors, fetchStatus } / { signUp, errors, fetchStatus }:

  • Method-based flows: signIn.password({...}), signIn.phoneCode.sendCode({...}), signUp.verifications.verifyEmailCode({...}). Every method resolves to { error: ClerkError | null } — check error, don't rely on try/catch for API errors.
  • errors — reactive error state; field-level errors at errors.fields.<name> (e.g. errors.fields.identifier, errors.fields.code).
  • fetchStatus — 'idle' | 'fetching'; use it to disable submit buttons.
  • signIn.status — 'needs_identifier' | 'needs_first_factor' | 'needs_second_factor' | 'needs_client_trust' | 'needs_new_password' | 'complete'.
  • signUp.status — 'missing_requirements' | 'complete', with signUp.unverifiedFields / signUp.missingFields saying what's left.
  • finalize({ navigate }) — converts a complete sign-in/up into the active session. Replaces setActive({ session }).
  • reset() — clears the attempt so the user can start over (local-only, no API call).

Never generate the legacy shape for new code: isLoaded/setActive destructured from useSignIn()/useSignUp() (the current hooks don't return them — isLoaded from useAuth()/useUser() is fine), or signIn.create() chained with prepareFirstFactor()/attemptFirstFactor() + setActive({ session: createdSessionId }). That API lives at @clerk/expo/legacy and is only for maintaining code that already uses it. (signIn.create() still exists on the new resource but is for advanced cases — prefer the factor-specific methods.)

If the installed @clerk/expo is older than 3.4 and hooks don't have this shape, tell the developer and offer to upgrade rather than writing legacy code.

Before writing flow code

  1. Check enabled factors (SKILL.md Gate 3). Derive the Frontend API URL from the publishable key and fetch <frontendApiUrl>/v1/environment?_is_native=true, or have the developer confirm in the dashboard. Only implement enabled strategies; if the user asked for a disabled one (common with SMS), tell them to enable it in Clerk Dashboard → User & authentication first.
  2. Combined flow by default — one screen that signs in or signs up, unless separation is requested.
  3. Captcha mount point — every screen that can create a sign-up must render <View nativeID="clerk-captcha" />.

Shared finalize helper

All flows end the same way. Session tasks (e.g. forced MFA enrollment, org selection) must be handled before navigating:

const navigateAfterAuth = ({ session, decorateUrl }) => {
  if (session?.currentTask) {
    // Route to your session-task UI instead of home.
    // https://clerk.com/docs/guides/development/custom-flows/authentication/session-tasks
    return
  }
  const url = decorateUrl('/')
  if (url.startsWith('http')) window.location.href = url // Expo web
  else router.push(url as Href)
}

// on status === 'complete':
await signIn.finalize({ navigate: navigateAfterAuth }) // same shape for signUp.finalize

Email + password (sign-in)

import { useSignIn } from '@clerk/expo'

const { signIn, errors, fetchStatus } = useSignIn()

const handleSubmit = async () => {
  const { error } = await signIn.password({ emailAddress, password })
  if (error) return // surface errors.fields.identifier / errors.fields.password in the UI

  if (signIn.status === 'complete') {
    await signIn.finalize({ navigate: navigateAfterAuth })
  } else if (signIn.status === 'needs_second_factor') {
    // MFA step — see below
  } else if (signIn.status === 'needs_client_trust') {
    // New-device verification: send a code, then verify with signIn.mfa.verifyEmailCode
    const emailFactor = signIn.supportedSecondFactors?.find((f) => f.strategy === 'email_code')
    if (emailFactor) await signIn.mfa.sendEmailCode()
  }
}

Sign-up mirror: signUp.password({ emailAddress, password }), then signUp.verifications.sendEmailCode(), collect the code, signUp.verifications.verifyEmailCode({ code }), then signUp.finalize(...). Show the verify step when signUp.status === 'missing_requirements' && signUp.unverifiedFields.includes('email_address').

Phone / SMS OTP

Requires Phone number + SMS verification code enabled in the dashboard (SMS is billable and instance-dependent — check first, Gate 3).

Sign-up:

const { signUp, errors, fetchStatus } = useSignUp()

const handleSubmit = async () => {
  const { error } = await signUp.create({ phoneNumber }) // E.164, e.g. +15551234567
  if (!error) await signUp.verifications.sendPhoneCode()
}

const handleVerify = async () => {
  await signUp.verifications.verifyPhoneCode({ code })
  if (signUp.status === 'complete') await signUp.finalize({ navigate: navigateAfterAuth })
}

// Show the code input when:
// signUp.status === 'missing_requirements' && signUp.unverifiedFields.includes('phone_number') && signUp.missingFields.length === 0
// Resend: signUp.verifications.sendPhoneCode()

Sign-in:

const { signIn, errors, fetchStatus } = useSignIn()

const handleSubmit = async () => {
  // Creates the sign-in attempt AND sends the SMS in one call — no signIn.create() needed
  const { error } = await signIn.phoneCode.sendCode({ phoneNumber })
  if (error) return // surface errors.fields
}

const handleVerify = async () => {
  await signIn.phoneCode.verifyCode({ code })
  if (signIn.status === 'complete') await signIn.finalize({ navigate: navigateAfterAuth })
}

// Resend: signIn.phoneCode.sendCode() with no args — the sign-in already exists

Email OTP is the same shape: emailCode.sendCode({ emailAddress }) (also self-creating) / emailCode.verifyCode({ code }) on sign-in, sendEmailCode() / verifyEmailCode() on sign-up verifications.

Combined sign-in-or-up

Attempt sign-in; on form_identifier_not_found, switch to sign-up with the same credentials:

const { error } = await signIn.password({ emailAddress, password })
if (error) {
  if (error.errors[0].code === 'form_identifier_not_found') {
    const { error: signUpError } = await signUp.password({ emailAddress, password })
    if (signUpError) return
    await signUp.verifications.sendEmailCode()
    if (signUp.unverifiedFields?.includes('email_address')) setShowCodeStep(true)
    return
  }
  return // real error — surface errors.fields
}
// continue sign-in path (complete / needs_second_factor / needs_client_trust)

For the OTP-only variant, do the same with signIn.phoneCode / signUp.create({ phoneNumber }).

MFA / 2FA (second factor)

When signIn.status === 'needs_second_factor', check signIn.supportedSecondFactors and use signIn.mfa:

Factor Send Verify
SMS code signIn.mfa.sendPhoneCode() signIn.mfa.verifyPhoneCode({ code })
Email code signIn.mfa.sendEmailCode() signIn.mfa.verifyEmailCode({ code })
TOTP (authenticator app) — signIn.mfa.verifyTOTP({ code })
Backup code — signIn.mfa.verifyBackupCode({ code })

After a successful verify, signIn.status becomes complete → finalize(). The same mfa methods serve needs_client_trust (new-device verification).

Other flows

  • Forgot password: signIn.resetPasswordEmailCode.sendCode() → verifyCode({ code }) (status becomes needs_new_password) → submitPassword({ password }). Phone variant: signIn.resetPasswordPhoneCode.*.
  • Email link: signIn.emailLink.sendLink({ ... }) + signIn.emailLink.waitForVerification().
  • Passkeys: signIn.passkey(); requires @clerk/expo-passkeys and dev build.
  • Social/SSO: see sso-and-native-auth.md — SSO does not use this method API.

Verification checklist

  • No legacy API in generated code (no prepareFirstFactor, no setActive({ session }) outside SSO).
  • Every implemented strategy confirmed enabled for the instance.
  • Errors surfaced from errors.fields.*; buttons disabled while fetchStatus === 'fetching'.
  • finalize({ navigate }) handles session.currentTask before navigating.
  • Sign-up screens include <View nativeID="clerk-captcha" />.
  • Verify step gated on status / unverifiedFields, with a resend button and a reset() escape hatch.
  • One real end-to-end auth exercised; session survives restart.

Source: SKILL.md on GitHub

No alerts16d3 checks · Risk SAFE
  • Gen Agent Trust Hub16d

    The analyzed skill defines safe, verified integration patterns for the @clerk/expo authentication library in Expo and React Native applications. It appropriately specifies standard secret management guidelines (storing publishable keys in environment files rather than hardcoding them) and does not exhibit any malicious behaviors such as data exfiltration, prompt injection, or obfuscation.

  • Socket16d

    No alerts

  • Snyk16d

    Risk: LOW · No issues

Signed by skilld at 3d0c898. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub yesterday.

Activeupdated 3 months ago
What it can do
Network
metadata
{
  "author": "clerk",
  "version": "2.0.0"
}
All 1 allowed tools
WebFetch
Other metadata
compatibility
Requires @clerk/expo v3.4+ (written against v3.6.x, July 2026). Expo SDK 53-56, React Native 0.75+.

README badge

README badge for clerk/skills/clerk-expo

Implements Clerk authentication in Expo and React Native projects using @clerk/expo, supporting either prebuilt AuthView/UserButton components or custom hook-driven flows. Handles ClerkProvider setup, token persistence, native sign-in strategies, and Expo config plugin registration—excludes native iOS/Swift, native Android/Kotlin, and web-only frameworks.

Generated from the current SKILL.md.

Does this skill work with native iOS/Swift or native Android/Kotlin projects?
No. This skill is for Expo and React Native only. Native iOS/Swift and native Android/Kotlin projects should use their respective Clerk SDKs, not this skill.
Do I need a development build or can I use Expo Go?
Native components like AuthView and UserButton require an iOS/Android development build. Expo Go does not support native modules. For web targets, use the @clerk/expo/web exports instead.
What's the difference between prebuilt and custom flows?
Prebuilt uses Clerk's AuthView or UserButton components for the fastest setup. Custom hook-driven flows give you full control over the UI and authentication state but require more implementation work.
Do I need to set the publishable key via environment variables?
No. Pass the publishable key directly to <ClerkProvider publishableKey={key}> unless you explicitly ask for environment-variable indirection. The skill will not introduce env-var wiring by default.
Does this skill cover Expo-specific recipes like token caching and protected routes?
This skill covers flow selection and end-to-end setup. Expo-specific recipes (SecureStore token cache, OAuth deep-link configuration, Expo Router protected routes) are in the clerk-expo-patterns skill instead.

Generated from the current SKILL.md. These answers refresh after source changes.