Skip to content

@clerk/expo v3 iOS: AuthView session not restored on cold start despite valid JWT in keychain (works on Android, same code) #8441

@brandonatdashtag

Description

@brandonatdashtag

Preliminary Checks

Reproduction

No standalone repro repo, but reproduces in any Expo SDK 54 + @clerk/expo 3.2.7 app on iOS using <AuthView> from @clerk/expo/native. We've reproduced it in two separate apps in our monorepo (one consumer app, one chat app) with completely different code.

Publishable key

pk_live_Y2xlcmsudGhlZGFzaHRhZy5jb20k

Description

After cold-start (force-quit + relaunch) on iOS only, the user's Clerk session is not restored from the JWT in expo-secure-store. The same code works perfectly on Android — sessions persist as expected.

Symptoms: user sees the unauthenticated/sign-in screen on every app launch, even though __clerk_client_jwt is intact in keychain.

Environment

Component Version
@clerk/expo 3.2.7 (also reproduced on 3.2.8-canary.v20260501194208)
clerk-ios (SwiftPM) 1.0.0
Expo SDK 54
React Native 0.81
iOS (physical device) 26.3.1 (iPhone 15 Pro Max)
macOS 26.3

Setup

  • <ClerkProvider publishableKey={CLERK_PUBLISHABLE_KEY} tokenCache={tokenCache}> (default tokenCache from @clerk/expo/token-cache)
  • <AuthView mode="signInOrUp" /> from @clerk/expo/native
  • useAuth({ treatPendingAsSignedOut: false }) everywhere
  • @clerk/expo plugin in app.config.ts plugins array
  • iOS bundle id com.dashtag.app.dev, signed with our developer cert

Diagnosis (with detailed traces)

I patched node_modules/@clerk/expo/dist/provider/ClerkProvider.js to add console logging and isolated the bug to native ClerkExpo.getSession() returning undefined indefinitely after cold-start, despite configure(pk, jwt) resolving successfully and a valid JWT being passed.

Cold-start trace (consistent pattern):

mount | isSignedIn=undefined isLoaded=false jwt=eyJhbGciOiJSUzI1NiIs...(len 518)
bootstrap: calling native configure with bearerToken= eyJhbGciOiJSUzI1NiIs...(len 518)
bootstrap: configure resolved          ← native promise resolves cleanly
mount | isSignedIn=false isLoaded=true ← Clerk JS finishes loading
bootstrap: poll #1 getSession() -> undefined
bootstrap: poll #5 getSession() -> undefined
bootstrap: poll #15 getSession() -> undefined
bootstrap: poll #30 getSession() -> undefined
bootstrap: NO SESSION after 30 polls (3s timeout — original behavior)

Workaround attempt #1 — extended polling 3s → 30s: marginal improvement, not reliable.

Workaround attempt #2 — re-call configure(pk, jwt) repeatedly: sometimes triggers eventual restore (~30% of attempts), but unreliable. After 60s of retries (20 × 3s intervals), getSession() still returns undefined for fresh sessions created moments before the test.

Workaround attempt #3 — block spurious setActive({session: null}): never observed this code path being triggered; the bug is purely in getSession() returning null.

What's interesting

  1. Same code, same JWT length, same Clerk user — works on Android, fails on iOS. I tested side-by-side with the same Metro instance serving both an Android device and an iPhone using the exact same JS bundle. Android: session restored within 1-2s of cold start, every time. iOS: session restored maybe 30% of the time, often never.

  2. The JWT in keychain is fresh and valid. Just-created sessions (signed in moments before the test) fail to restore.

  3. The ClerkViewFactory.swift syncTokenState logic looks suspect. Lines 72-91 of node_modules/@clerk/expo/ios/ClerkViewFactory.swift compare the JS-keychain JWT against the nativeDeviceToken keychain entry; if they differ, clearCachedClerkData() is called to wipe cachedClient and cachedEnvironment. After the cache wipe, the SDK relies on a network round-trip to repopulate. If that round-trip is slow or fails silently, Clerk.shared.session stays nil and getSession() returns null. This iOS-specific keychain-dance code does not have an Android equivalent — which matches our observation that Android always works.

  4. The configure() promise resolving says nothing about whether the session is loaded. Clerk.shared.session might still be nil when configure() resolves.

Suggested fix

Either:

  • Make ClerkExpo.configure(pk, bearerToken) not resolve until Clerk.shared.session is non-nil OR a definitive failure has occurred (currently waitForLoadedSession() polls but the promise resolves regardless).
  • Surface an onAuthStateChange event when the native SDK eventually loads the session post-configure(), so the JS bootstrap can react via useNativeAuthEvents instead of polling getSession().
  • Avoid clearCachedClerkData() on cold-start when existingToken == nil (first-launch case) — only clear when the token actually changed and there's a stale cached client to reconcile.

Related issues

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions