Expo SecureStore Storage Adapter#
The storageAdapter function in packages/expo/src/client.ts is a thin wrapper around any user-supplied synchronous storage object (typically expo-secure-store). It is the single point where all cookie and session data flows to and from device storage in the expoClient plugin. The adapter transparently handles three platform-specific constraints: synchronous storage contract, iOS Keychain size limits, and key naming restrictions.
Why a Wrapper Is Needed#
expoClient requires the caller-supplied storage to expose a synchronous getItem / setItem interface . Expo SecureStore's native API is asynchronous, so the caller must pass a synchronous in-memory mirror (e.g. via expo-secure-store's synchronous read path, or a custom adapter) — the storageAdapter itself adds async behaviour only on writes. The adapter's setItem is async while getItem remains synchronous, matching the needs of the onSuccess fetch hook and the session-restore path that run at startup.
Key Normalization#
Expo SecureStore does not allow colons in key names. Better Auth cookie names contain colons (e.g. better-auth:session_token). The normalizeCookieName function replaces every : with _ before any read or write. Both getItem and setItem in the adapter call it on entry , so callers always use canonical cookie names; normalization is invisible. This fix was introduced in PR #5432.
iOS Keychain Chunking#
iOS Keychain silently drops writes whose value exceeds ~2 KB. This caused large OAuth tokens (e.g., from Keycloak) to be silently lost, leaving useSession() returning null . PR #9815 solved this by splitting oversized values across multiple storage keys.
Constants :
STORAGE_VALUE_LIMIT = 1800— max characters per writeCHUNK_MARKER = "\u0001ba-chunks:"— prefix stored at the base key to signal a chunked value; the\u0001control character cannot start a JSON value, preventing accidental collisions
Write path (setItem):
- If
value.length <= 1800, write directly to the base key (backward-compatible fast path). - Otherwise, clear the base key, write each 1800-char slice to
${key}.0,${key}.1, …${key}.N, then writeCHUNK_MARKER + countto the base key as a commit point. - Errors are caught and logged via
console.error; they do not propagate — persistence is best-effort.
Read path (getItem):
- If the stored value does not start with
CHUNK_MARKER, return it as-is (handles both unchunked values and legacy data). - Parse the chunk count, iterate
${key}.0…${key}.N, concatenate, and return. If any chunk is missing, returnnull— torn writes fail closed.
How It Fits Into expoClient#
The adapter is instantiated once inside expoClient and used for all three storage keys:
| Key | Purpose |
|---|---|
${storagePrefix}_cookie | Persisted cookie jar (JSON map of { value, expires }) |
${storagePrefix}_session_data | Local session cache for instant startup |
All writes in the onSuccess fetch hook (await storage.setItem(...)) and the session-restore path (storage.getItem(...)) go through the adapter . Sign-out calls clearSessionCache, which writes "{}" to both keys via the same adapter .
Source References#
| Item | Location |
|---|---|
storageAdapter implementation | client.ts:274–340 |
normalizeCookieName | client.ts:253–255 |
STORAGE_VALUE_LIMIT / CHUNK_MARKER constants | client.ts:265–272 |
| Chunking PR (iOS 2KB fix) | PR #9815 |
| Key normalization PR (colon fix) | PR #5432 |
| Original colon issue | https://github.com/better-auth/better-auth/issues/5426 |
| Original 2KB issue | https://github.com/better-auth/better-auth/issues/9151 |