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/nativeuseAuth({ 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
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.
The JWT in keychain is fresh and valid. Just-created sessions (signed in moments before the test) fail to restore.
The ClerkViewFactory.swiftsyncTokenState 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.
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
Preliminary Checks
Reproduction
No standalone repro repo, but reproduces in any Expo SDK 54 +
@clerk/expo3.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_Y2xlcmsudGhlZGFzaHRhZy5jb20kDescription
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_jwtis intact in keychain.Environment
@clerk/expoclerk-ios(SwiftPM)Setup
<ClerkProvider publishableKey={CLERK_PUBLISHABLE_KEY} tokenCache={tokenCache}>(defaulttokenCachefrom@clerk/expo/token-cache)<AuthView mode="signInOrUp" />from@clerk/expo/nativeuseAuth({ treatPendingAsSignedOut: false })everywhere@clerk/expoplugin inapp.config.tsplugins arraycom.dashtag.app.dev, signed with our developer certDiagnosis (with detailed traces)
I patched
node_modules/@clerk/expo/dist/provider/ClerkProvider.jsto add console logging and isolated the bug to nativeClerkExpo.getSession()returningundefinedindefinitely after cold-start, despiteconfigure(pk, jwt)resolving successfully and a valid JWT being passed.Cold-start trace (consistent pattern):
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 returnsundefinedfor 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 ingetSession()returning null.What's interesting
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.
The JWT in keychain is fresh and valid. Just-created sessions (signed in moments before the test) fail to restore.
The
ClerkViewFactory.swiftsyncTokenStatelogic looks suspect. Lines 72-91 ofnode_modules/@clerk/expo/ios/ClerkViewFactory.swiftcompare the JS-keychain JWT against thenativeDeviceTokenkeychain entry; if they differ,clearCachedClerkData()is called to wipecachedClientandcachedEnvironment. 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.sessionstays nil andgetSession()returns null. This iOS-specific keychain-dance code does not have an Android equivalent — which matches our observation that Android always works.The
configure()promise resolving says nothing about whether the session is loaded.Clerk.shared.sessionmight still be nil whenconfigure()resolves.Suggested fix
Either:
ClerkExpo.configure(pk, bearerToken)not resolve untilClerk.shared.sessionis non-nil OR a definitive failure has occurred (currentlywaitForLoadedSession()polls but the promise resolves regardless).onAuthStateChangeevent when the native SDK eventually loads the session post-configure(), so the JS bootstrap can react viauseNativeAuthEventsinstead of pollinggetSession().clearCachedClerkData()on cold-start whenexistingToken == nil(first-launch case) — only clear when the token actually changed and there's a stale cached client to reconcile.Related issues
@clerk/expov3 session not restored on Metro JS reload (Android variant)useAuth().isLoadedpermanently false in v3@clerk/clerk-expov2 did not have these regressions.