Better Auth on Expo: SecureStore sessions, cookiePrefix match, getCookie APIs

Better Auth’s Expo client already solves the hard part of native sessions: it caches session data in expo-secure-store, filters cookies with cookiePrefix, and exposes getCookie() so your own API calls can send the same credentials the auth client uses. Wire those three pieces once and signed-in screens stop flashing empty on cold start, and authenticated fetch / tRPC calls stop arriving as anonymous.
This is the Expo-native path documented in the Better Auth Expo integration: expoClient + SecureStore storage, server trustedOrigins for your app scheme, and manual Cookie headers with credentials: "omit" when you call non-auth endpoints. It is a different stack than Supabase Auth session persistence in Expo — same product problem (stay signed in + call your API), different client APIs.
Install the Expo client and SecureStore
On the server, install better-auth and @better-auth/expo, then mount the Expo plugin. On the app, install the same packages plus expo-secure-store and expo-network. Social providers also need expo-linking, expo-web-browser, and expo-constants when you are not on the default Expo template.
import { betterAuth } from "better-auth";
import { expo } from "@better-auth/expo";
export const auth = betterAuth({
plugins: [expo()],
emailAndPassword: { enabled: true },
trustedOrigins: ["myapp://"],
});The client must import createAuthClient from better-auth/react and the Expo client plugin from @better-auth/expo/client. Pass SecureStore as storage so session data and cookies land in the encrypted keychain / Keystore instead of AsyncStorage.
import { createAuthClient } from "better-auth/react";
import { expoClient } from "@better-auth/expo/client";
import * as SecureStore from "expo-secure-store";
export const authClient = createAuthClient({
baseURL: process.env.EXPO_PUBLIC_AUTH_URL, // YOUR-API-ORIGIN including /api/auth path if customized
plugins: [
expoClient({
scheme: "myapp",
storagePrefix: "myapp",
storage: SecureStore,
}),
],
});Match scheme to app.json / app.config and keep that scheme in server trustedOrigins (for example myapp:// or myapp://*). Development builds that use Expo Go also need careful exp:// wildcards — only in development, never in production configs.
Why SecureStore session cache matters on native
On native, Better Auth caches session data in SecureStore so useSession can return the last known user without a spinner every time the process restarts. That is explicit in the Expo docs: session data is cached in SecureStore, and you can opt out with disableCache: true if you prefer always hitting the network.
import { Text } from "react-native";
import { authClient } from "@/lib/auth-client";
export default function HomeHeader() {
const { data: session } = authClient.useSession();
return <Text>Welcome, {session?.user.name}</Text>;
}Treat the cache as a UX win, not a security boundary. Server-side session management still owns expiration (expiresIn defaults to 7 days, updateAge defaults to 1 day), freshness (freshAge), and revocation. If you enable session.cookieCache on the server, remember revoked sessions can look live until that short-lived cache cookie expires — shorten maxAge or force disableCookieCache on sensitive operations when immediate revoke matters.
One codebase. iOS, Android, and web.
The Fitness Kit ships with auth, a database, and a backend already connected — no setup. Live demo at fitness-preview.otf-kit.dev.
Align cookiePrefix on server and Expo client
Better Auth cookies default to the ${prefix}.${cookie_name} shape with prefix better-auth (cookies concept). The Expo client’s cookiePrefix option tells the plugin which cookie names belong to Better Auth so third-party cookies do not trigger infinite refetch loops. Default on the client is also "better-auth".
If you customize the server prefix, mirror it on the client:
// server
export const auth = betterAuth({
advanced: { cookiePrefix: "my-app" },
plugins: [expo()],
trustedOrigins: ["myapp://"],
});
// expo client
expoClient({
storage: SecureStore,
cookiePrefix: "my-app", // must match advanced.cookiePrefix
});You can pass an array when more than one prefix is in play (for example passkey cookies with a custom name). Mismatch here is a common “session works in the auth client but my app keeps refetching / ignoring cookies” failure mode — fix the prefix before rewriting storage.
Call your API with getCookie and credentials omit
Auth requests go through the Better Auth client and pick up SecureStore cookies automatically. Your own secure endpoints do not. The Expo docs require you to read the cookie string and attach it yourself:
import { authClient } from "@/lib/auth-client";
export async function fetchSecureJson(path: string) {
const cookies = await authClient.getCookie();
const response = await fetch(`${process.env.EXPO_PUBLIC_API_URL}${path}`, {
headers: { Cookie: cookies },
// 'include' can interfere with cookies set manually in headers
credentials: "omit",
});
if (!response.ok) throw new Error(`secure fetch failed: ${response.status}`);
return response.json();
}The same pattern plugs into tRPC httpBatchLink headers: await authClient.getCookie(), set Cookie when present, return a plain object. Skipping credentials: "omit" after you manually set Cookie is a frequent footgun — the runtime’s include behavior can collide with the header you just built.
Gate product screens on session presence the same way you would for any Expo Router root guard. Pair this client with a root layout check so unsigned users never land on paid flows — the same outcome covered in Expo Router protected routes, independent of which auth vendor issues the cookie.

Scheme, trusted origins, and social return paths
OAuth and social sign-in need a deep link back into the app. Define scheme in Expo config, list it under server trustedOrigins, and pass relative callbackURL values like "/dashboard" so the Expo plugin can turn them into deep links via Linking.createURL. On iOS/Android, signIn.social does not navigate for you — wait for the promise, then router.replace yourself.
Id-token flows (Google / Apple / Facebook) keep the provider SDK on-device and hand Better Auth { token } (and optional nonce). That path still depends on the same SecureStore-backed client afterward: once the session exists, useSession and getCookie behave like email/password.
Metro note from the docs: Better Auth needs package exports. On Expo SDK 53+ (including the SDK 55 guide current as of this writing) exports are on by default — do not set unstable_enablePackageExports to false. Clear the Metro cache after config changes with npx expo start --clear.
Production checklist before you ship the gate
expo()on the server andexpoClienton the app with the same storage instance everywhere that reads those keys.- SecureStore installed;
disableCacheonly if you intentionally accept cold-start spinners. cookiePrefixidentical to serveradvanced.cookiePrefix(or leave both at defaultbetter-auth).trustedOriginsincludes production scheme only; stripexp://wildcards from production configs.- Every non-auth authenticated call uses
getCookie()+Cookieheader +credentials: "omit". - Session revoke / password-change flows tested against both the SecureStore cache and any server
cookieCachemaxAge. - Route gate: unsigned users never reach billing or private data screens.

Where owned kits fit
If you are assembling auth plus billing by hand, the session gate above is the native client half. Owned full-stack kits already ship an auth and billing spine you can extend with agent prompts instead of regenerating the stack — see auth and billing you do not hand-roll and the live kit catalog on /templates. This post stays on the Better Auth Expo client contract; the kit path is the complementary repo you own when you want screens, schema, and Stripe already wired.
Keep the mental model small: SecureStore holds the cached session, cookiePrefix decides which cookies count, getCookie is how every other request proves the same identity. Get those three right and the rest of your Expo app can treat “is the user signed in?” as a solved primitive instead of a per-screen race.
Sources
- Better Auth Expo integration — SecureStore cache,
expoClientoptions,getCookie+credentials: "omit",cookiePrefix - Better Auth session management —
expiresIn/updateAge, freshness, cookie cache strategies - Better Auth cookies — default
better-authprefix and${prefix}.${cookie_name}format
Stop wiring. Start shipping.
- Login, database, and backend already connected — nothing to set up
- iOS + Android + web from one codebase
- AI configs pre-tuned + 40+ tested prompts included