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
-
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.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.
-
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.