[GH-ISSUE #8952] Session data written to SecureStore is never read back — causes login screen flash on app restart #11244

Open
opened 2026-04-13 07:35:28 -05:00 by GiteaMirror · 2 comments
Owner

Originally created by @terijaki on GitHub (Apr 4, 2026).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/8952

Is this suited for github?

  • Yes, this is suited for github

Reproduction

  1. Create an Expo app with @better-auth/expo and expo-secure-store
  2. Sign in (any method — email/password, social, etc.)
  3. Confirm session works and _session_data is written to SecureStore
  4. Close the app completely (kill from app switcher)
  5. Reopen the app
  6. Observe useSession() returns { data: null, isPending: true } for 1-2 seconds while the network request to /get-session completes

Current vs. Expected behavior

Current behavior:

The Expo client writes session data to SecureStore after every successful /get-session response:

// packages/expo/src/client.ts — onSuccess hook
if (context.request.url.toString().includes("/get-session") && !opts?.disableCache) {
  const data = context.data;
  storage.setItem(localCacheName, JSON.stringify(data)); // ← WRITE
}

However, localCacheName (${storagePrefix}_session_data) is never read back from storage. The only reads from SecureStore are for cookieName (${storagePrefix}_cookie), which holds the cookie jar — not the session data.

This means the session atom always initializes as { data: null, isPending: true } on every app start, regardless of whether valid cached session data exists in SecureStore. Apps that use useSession() for auth-based routing (e.g., with Expo Router's Stack.Protected) will briefly flash the login screen until the /get-session network request completes.

Expected behavior:

On app startup (non-web), the client should read localCacheName from SecureStore and use it as the initial/optimistic value for the session atom while the /get-session network request is in flight. This is the standard stale-while-revalidate pattern — show cached data immediately, then replace it once the fresh response arrives.

A minimal fix could look like:

// In getActions or during plugin initialization:
if (!isWeb && !opts?.disableCache) {
  const cached = storage.getItem(localCacheName); // SecureStore.getItem is synchronous on native
  if (cached && cached !== "{}") {
    const parsed = safeJSONParse(cached);
    if (parsed?.session?.id && parsed?.user?.id) {
      store.atoms.session?.set({
        data: parsed,
        error: null,
        isPending: true, // still pending — network will confirm
      });
    }
  }
}

This would eliminate the login screen flash for returning users while still validating the session server-side.

What version of Better Auth are you using?

1.5.6

System info

{
  "system": {
    "platform": "darwin",
    "arch": "arm64",
    "version": "Darwin Kernel Version 25.4.0: Thu Mar 19 19:30:44 PDT 2026; root:xnu-12377.101.15~1/RELEASE_ARM64_T6000",
    "release": "25.4.0",
    "cpuCount": 10,
    "cpuModel": "Apple M1 Max",
    "totalMemory": "32.00 GB",
    "freeMemory": "1.50 GB"
  },
  "node": {
    "version": "v24.14.1",
    "env": "development"
  },
  "packageManager": {
    "name": "bun",
    "version": "1.3.11"
  },
  "frameworks": null,
  "databases": null,
  "betterAuth": {
    "version": "Unknown",
    "config": null
  }
}

Which area(s) are affected? (Select all that apply)

Client

Auth config (if applicable)


Additional context

Environment information

@better-auth/expo: 1.5.6
better-auth: 1.5.6
expo: ~53.0.11
expo-secure-store: ~15.0.2
React Native: 0.79.2
Platform: iOS & Android

Workaround: We currently work around this by reading the SecureStore cache ourselves at module load time and using it as a fallback in the auth guard:

// session-cache.ts
import * as SecureStore from "expo-secure-store";
import { Platform } from "react-native";

const CACHE_KEY = `${storagePrefix}_session_data`;

export function getCachedSession() {
  if (Platform.OS === "web") return null;
  try {
    const raw = SecureStore.getItem(CACHE_KEY);
    if (!raw || raw === "{}") return null;
    const parsed = JSON.parse(raw);
    if (parsed?.session?.id && parsed?.user?.id) return parsed;
    return null;
  } catch {
    return null;
  }
}
// _layout.tsx — auth guard
const cachedSession = getCachedSession();
// ...
const effectiveSession = session ?? cachedSession;
const hasSession = Boolean(effectiveSession?.user?.id) || isPending;

This works but is fragile because it depends on the internal cache key format (${storagePrefix}_session_data). Ideally the library itself would handle this.

Originally created by @terijaki on GitHub (Apr 4, 2026). Original GitHub issue: https://github.com/better-auth/better-auth/issues/8952 ### Is this suited for github? - [x] Yes, this is suited for github ### Reproduction 1. Create an Expo app with `@better-auth/expo` and `expo-secure-store` 2. Sign in (any method — email/password, social, etc.) 3. Confirm session works and `_session_data` is written to SecureStore 4. Close the app completely (kill from app switcher) 5. Reopen the app 6. Observe `useSession()` returns `{ data: null, isPending: true }` for 1-2 seconds while the network request to `/get-session` completes ### Current vs. Expected behavior **Current behavior:** The Expo client writes session data to SecureStore after every successful `/get-session` response: ```ts // packages/expo/src/client.ts — onSuccess hook if (context.request.url.toString().includes("/get-session") && !opts?.disableCache) { const data = context.data; storage.setItem(localCacheName, JSON.stringify(data)); // ← WRITE } ``` However, `localCacheName` (`${storagePrefix}_session_data`) is **never read back** from storage. The only reads from SecureStore are for `cookieName` (`${storagePrefix}_cookie`), which holds the cookie jar — not the session data. This means the session atom always initializes as `{ data: null, isPending: true }` on every app start, regardless of whether valid cached session data exists in SecureStore. Apps that use `useSession()` for auth-based routing (e.g., with Expo Router's `Stack.Protected`) will briefly flash the login screen until the `/get-session` network request completes. **Expected behavior:** On app startup (non-web), the client should read `localCacheName` from SecureStore and use it as the initial/optimistic value for the session atom while the `/get-session` network request is in flight. This is the standard stale-while-revalidate pattern — show cached data immediately, then replace it once the fresh response arrives. A minimal fix could look like: ```ts // In getActions or during plugin initialization: if (!isWeb && !opts?.disableCache) { const cached = storage.getItem(localCacheName); // SecureStore.getItem is synchronous on native if (cached && cached !== "{}") { const parsed = safeJSONParse(cached); if (parsed?.session?.id && parsed?.user?.id) { store.atoms.session?.set({ data: parsed, error: null, isPending: true, // still pending — network will confirm }); } } } ``` This would eliminate the login screen flash for returning users while still validating the session server-side. ### What version of Better Auth are you using? 1.5.6 ### System info ```bash { "system": { "platform": "darwin", "arch": "arm64", "version": "Darwin Kernel Version 25.4.0: Thu Mar 19 19:30:44 PDT 2026; root:xnu-12377.101.15~1/RELEASE_ARM64_T6000", "release": "25.4.0", "cpuCount": 10, "cpuModel": "Apple M1 Max", "totalMemory": "32.00 GB", "freeMemory": "1.50 GB" }, "node": { "version": "v24.14.1", "env": "development" }, "packageManager": { "name": "bun", "version": "1.3.11" }, "frameworks": null, "databases": null, "betterAuth": { "version": "Unknown", "config": null } } ``` ### Which area(s) are affected? (Select all that apply) Client ### Auth config (if applicable) ```typescript ``` ### Additional context **Environment information** ``` @better-auth/expo: 1.5.6 better-auth: 1.5.6 expo: ~53.0.11 expo-secure-store: ~15.0.2 React Native: 0.79.2 Platform: iOS & Android ``` **Workaround:** We currently work around this by reading the SecureStore cache ourselves at module load time and using it as a fallback in the auth guard: ```ts // session-cache.ts import * as SecureStore from "expo-secure-store"; import { Platform } from "react-native"; const CACHE_KEY = `${storagePrefix}_session_data`; export function getCachedSession() { if (Platform.OS === "web") return null; try { const raw = SecureStore.getItem(CACHE_KEY); if (!raw || raw === "{}") return null; const parsed = JSON.parse(raw); if (parsed?.session?.id && parsed?.user?.id) return parsed; return null; } catch { return null; } } ``` ```ts // _layout.tsx — auth guard const cachedSession = getCachedSession(); // ... const effectiveSession = session ?? cachedSession; const hasSession = Boolean(effectiveSession?.user?.id) || isPending; ``` This works but is fragile because it depends on the internal cache key format (`${storagePrefix}_session_data`). Ideally the library itself would handle this.
GiteaMirror added the bug label 2026-04-13 07:35:28 -05:00
Author
Owner

@dosubot[bot] commented on GitHub (Apr 4, 2026):

I can confirm this is a real bug. The code in packages/expo/src/client.ts writes session data to localCacheName on every successful /get-session response [1], but there is no corresponding storage.getItem(localCacheName) call anywhere during initialization to read it back. The session atom always starts with { data: null, isPending: true } [2], and session data is populated exclusively through network requests — never from the cache.

This has been reported by other users as well [3], and no existing PR addresses it. PR #7552 fixed duplicate /get-session refetches during startup [4], but the fundamental cache restoration on boot remains unimplemented.

Your proposed fix and workaround both look sound. A couple of additional considerations for the official fix:

  • Expiration validation — the cached data should check expiresAt before hydrating the atom to avoid briefly showing a stale/expired session.
  • Distinguishing network errors from auth failures — a related issue [5] notes that useSession() sets data: null on any failure after retries, without distinguishing transient network errors from actual authentication failures. Combining cache restoration with smarter error handling would make the offline experience much more robust.

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other
Chat with Dosu Open in Cursor Join Discord Share on X

<!-- gh-comment-id:4186968206 --> @dosubot[bot] commented on GitHub (Apr 4, 2026): <!-- Answer --> I can confirm this is a real bug. The code in `packages/expo/src/client.ts` writes session data to `localCacheName` on every successful `/get-session` response [[1]](https://github.com/better-auth/better-auth/blob/d06b5865f47bd1f929b5d844c5a31e2762ceeef5/packages/expo/src/client.ts#L344-L350), but there is no corresponding `storage.getItem(localCacheName)` call anywhere during initialization to read it back. The session atom always starts with `{ data: null, isPending: true }` [[2]](https://github.com/better-auth/better-auth/blob/d06b5865f47bd1f929b5d844c5a31e2762ceeef5/packages/better-auth/src/client/session-atom.ts#L14-L38), and session data is populated exclusively through network requests — never from the cache. This has been reported by other users as well [[3]](https://github.com/better-auth/better-auth/issues/1443#issuecomment-3649118189#L86-L115), and no existing PR addresses it. PR #7552 fixed duplicate `/get-session` refetches during startup [[4]](https://github.com/better-auth/better-auth/pull/7552), but the fundamental cache restoration on boot remains unimplemented. Your proposed fix and workaround both look sound. A couple of additional considerations for the official fix: - **Expiration validation** — the cached data should check `expiresAt` before hydrating the atom to avoid briefly showing a stale/expired session. - **Distinguishing network errors from auth failures** — a related issue [[5]](https://github.com/better-auth/better-auth/issues/8420#L1-L80) notes that `useSession()` sets `data: null` on any failure after retries, without distinguishing transient network errors from actual authentication failures. Combining cache restoration with smarter error handling would make the offline experience much more robust. <!-- Dosu Comment Footer --> *To reply, just mention [@dosu](https://go.dosu.dev/dosubot?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=brand-link).* --- <sup>How did I do? [Good](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-great_response#message_id=ddab50be-b8de-4f6d-a024-74d388710ae9&feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-irrelevant_answer#message_id=ddab50be-b8de-4f6d-a024-74d388710ae9&feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-incorrect_sources#message_id=ddab50be-b8de-4f6d-a024-74d388710ae9&feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-too_verbose#message_id=ddab50be-b8de-4f6d-a024-74d388710ae9&feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-hallucination#message_id=ddab50be-b8de-4f6d-a024-74d388710ae9&feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-bug_report#message_id=ddab50be-b8de-4f6d-a024-74d388710ae9&feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-other#message_id=ddab50be-b8de-4f6d-a024-74d388710ae9&feedback_type=other)</sup> [![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=chat-badge)&nbsp;[![Open in Cursor](https://dosu.dev/dosu-in-cursor.svg)](https://cursor.com/link/prompt?text=I%20can%20confirm%20this%20is%20a%20real%20bug.%20The%20code%20in%20%60packages/expo/src/client.ts%60%20writes%20session%20data%20to%20%60localCacheName%60%20on%20every%20successful%20%60/get-session%60%20response%20%5B%5B1%5D%5D%28https%3A//github.com/better-auth/better-auth/blob/d06b5865f47bd1f929b5d844c5a31e2762ceeef5/packages/expo/src/client.ts%23L344-L350%29%2C%20but%20there%20is%20no%20corresponding%20%60storage.getItem%28localCacheName%29%60%20call%20anywhere%20during%20initialization%20to%20read%20it%20back.%20The%20session%20atom%20always%20starts%20with%20%60%7B%20data%3A%20null%2C%20isPending%3A%20true%20%7D%60%20%5B%5B2%5D%5D%28https%3A//github.com/better-auth/better-auth/blob/d06b5865f47bd1f929b5d844c5a31e2762ceeef5/packages/better-auth/src/client/session-atom.ts%23L14-L38%29%2C%20and%20session%20data%20is%20populated%20exclusively%20through%20network%20requests%20%E2%80%94%20never%20from%20the%20cache.%0A%0AThis%20has%20been%20reported%20by%20other%20users%20as%20well%20%5B%5B3%5D%5D%28https%3A//github.com/better-auth/better-auth/issues/1443%23issuecomment-3649118189%23L86-L115%29%2C%20and%20no%20existing%20PR%20addresses%20it.%20PR%20%237552%20fixed%20duplicate%20%60/get-session%60%20refetches%20during%20startup%20%5B%5B4%5D%5D%28https%3A//github.com/better-auth/better-auth/pull/7552%29%2C%20but%20the%20fundamental%20cache%20restoration%20on%20boot%20remains%20unimplemented.%0A%0AYour%20proposed%20fix%20and%20workaround%20both%20look%20sound.%20A%20couple%20of%20additional%20considerations%20for%20the%20official%20fix%3A%0A%0A-%20%2A%2AExpiration%20validation%2A%2A%20%E2%80%94%20the%20cached%20data%20should%20check%20%60expiresAt%60%20before%20hydrating%20the%20atom%20to%20avoid%20briefly%20showing%20a%20stale/expired%20session.%0A-%20%2A%2ADistinguishing%20network%20errors%20from%20auth%20failures%2A%2A%20%E2%80%94%20a%20related%20issue%20%5B%5B5%5D%5D%28https%3A//github.com/better-auth/better-auth/issues/8420%23L1-L80%29%20notes%20that%20%60useSession%28%29%60%20sets%20%60data%3A%20null%60%20on%20any%20failure%20after%20retries%2C%20without%20distinguishing%20transient%20network%20errors%20from%20actual%20authentication%20failures.%20Combining%20cache%20restoration%20with%20smarter%20error%20handling%20would%20make%20the%20offline%20experience%20much%20more%20robust.)&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=join-discord)&nbsp;[![Share on X](https://img.shields.io/badge/X-share-black)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/better-auth/better-auth/issues/8952)
Author
Owner

@kiikoh commented on GitHub (Apr 12, 2026):

I am also experiencing this behavior when using better auth with expo

<!-- gh-comment-id:4232161643 --> @kiikoh commented on GitHub (Apr 12, 2026): I am also experiencing this behavior when using better auth with expo
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#11244