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

> Cache Better Auth sessions in Expo SecureStore, align cookiePrefix with the server, and attach getCookie for API calls with credentials omit.
> By Dave · 2026-09-18
> Source: https://otf-kit.dev/blog/better-auth-expo-session-gate

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](https://better-auth.com/docs/integrations/expo): `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](/blog/supabase-auth-expo-session-production) — 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.

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

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

```tsx
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](https://better-auth.com/docs/concepts/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.

## Align cookiePrefix on server and Expo client

Better Auth cookies default to the `${prefix}.${cookie_name}` shape with prefix `better-auth` ([cookies concept](https://better-auth.com/docs/concepts/cookies)). 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:

```ts
// 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:

```ts
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](/blog/expo-router-protected-routes-gate), independent of which auth vendor issues the cookie.

![Match cookiePrefix on the Expo client to the server cookie prefix](https://cdn.otf-kit.dev/blog/better-auth-expo-session-gate/inbody1-20260918c.png)

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

1. `expo()` on the server and `expoClient` on the app with the same storage instance everywhere that reads those keys.
2. SecureStore installed; `disableCache` only if you intentionally accept cold-start spinners.
3. `cookiePrefix` identical to server `advanced.cookiePrefix` (or leave both at default `better-auth`).
4. `trustedOrigins` includes production scheme only; strip `exp://` wildcards from production configs.
5. Every non-auth authenticated call uses `getCookie()` + `Cookie` header + `credentials: "omit"`.
6. Session revoke / password-change flows tested against both the SecureStore cache and any server `cookieCache` `maxAge`.
7. Route gate: unsigned users never reach billing or private data screens.

![Attach getCookie on API calls and use credentials omit](https://cdn.otf-kit.dev/blog/better-auth-expo-session-gate/inbody2-20260918c.png)

## 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](/blog/auth-billing-you-dont-hand-roll) and the live kit catalog on [/templates](/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](https://better-auth.com/docs/integrations/expo) — SecureStore cache, `expoClient` options, `getCookie` + `credentials: "omit"`, `cookiePrefix`
- [Better Auth session management](https://better-auth.com/docs/concepts/session-management) — `expiresIn` / `updateAge`, freshness, cookie cache strategies
- [Better Auth cookies](https://better-auth.com/docs/concepts/cookies) — default `better-auth` prefix and `${prefix}.${cookie_name}` format
