Multi-Platform Authentication with Better Auth and Expo

January 25, 2025 · Eden Stack Team

authenticationbetter-authexpomobilereact-native

Multi-Platform Authentication with Better Auth and Expo

Building authentication that works across web and mobile is tricky. Cookies behave differently, storage APIs vary, OAuth callbacks need different handling, and you want to avoid duplicating your auth logic.

Eden Stack solves this with Better Auth + Expo client plugin. One auth configuration, two platforms, zero code duplication.

The Challenge

Web and mobile have fundamentally different auth primitives:

AspectWebMobile
Session storageCookies (HTTP-only)SecureStore
OAuth callbackURL redirectDeep link
OriginKnown domainsDevice-specific
Token handlingAutomatic (cookies)Manual headers

Better Auth's Expo plugin bridges these differences.

Server-Side Configuration

The auth server handles both web and mobile clients from the same configuration:

// packages/auth/src/index.ts
import { expo } from "@better-auth/expo";
import { db } from "@eden/db";
import * as schema from "@eden/db";
import { betterAuth, type BetterAuthPlugin } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { emailOTP } from "better-auth/plugins";
 
// Fix for Expo's origin handling
const expoOriginFix = (): BetterAuthPlugin => ({
  id: "expo-origin-fix",
  onRequest: async (request) => {
    const expoOrigin = request.headers.get("expo-origin");
    if (!expoOrigin) return;
 
    // Rewrite origin header for CORS
    const newHeaders = new Headers(request.headers);
    newHeaders.set("origin", expoOrigin);
 
    const newRequest = new Request(request.url, {
      method: request.method,
      headers: newHeaders,
      body: request.body,
      duplex: "half",
    } as RequestInit);
 
    return { request: newRequest };
  },
});
 
export const auth = betterAuth({
  database: drizzleAdapter(db, {
    provider: "pg",
    usePlural: true,
    schema: {
      users: schema.users,
      sessions: schema.sessions,
      accounts: schema.accounts,
      verifications: schema.verifications,
    },
  }),
 
  plugins: [
    expoOriginFix(),
    expo({ disableOriginOverride: true }),
    emailOTP({
      async sendVerificationOTP({ email, otp, type }) {
        await sendEmail({
          to: email,
          subject: type === "sign-in" 
            ? "Your login code" 
            : "Verify your email",
          template: LoginCodeEmail({ otp, type }),
        });
      },
      otpLength: 6,
      expiresIn: 300, // 5 minutes
    }),
  ],
 
  // OAuth providers
  socialProviders: {
    google: {
      clientId: process.env.GOOGLE_CLIENT_ID ?? "",
      clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? "",
    },
  },
 
  session: {
    expiresIn: 60 * 60 * 24 * 7, // 7 days
    updateAge: 60 * 60 * 24,     // Refresh daily
  },
 
  // Trust mobile deep link origins
  trustedOrigins:
    process.env.NODE_ENV === "development"
      ? (request) => {
          const origin = request?.headers.get("origin") ?? "";
          const staticOrigins = [
            "http://localhost:3000",
            "http://localhost:3001",
            "http://localhost:8081",
            "eden://",
            "exp://",
          ];
          // Allow local network IPs for mobile dev
          const isDevOrigin = /^(http:\/\/(192\.168|10\.)\d+\.\d+:\d+|exp:\/\/|eden:\/\/)/.test(origin);
          if (isDevOrigin && origin) {
            return [...staticOrigins, origin];
          }
          return staticOrigins;
        }
      : [process.env.BETTER_AUTH_URL ?? "http://localhost:3000"],
 
  account: {
    accountLinking: { enabled: true },
    // Required for mobile OAuth - cookies don't persist in in-app browser
    skipStateCookieCheck: true,
  },
});

Key points:

  1. expo() plugin: Adds mobile-specific endpoints and session handling
  2. expoOriginFix(): Rewrites expo-origin header to proper origin for CORS
  3. trustedOrigins: Accepts deep link schemes (eden://, exp://)
  4. skipStateCookieCheck: Bypasses OAuth state cookie for in-app browsers

Mobile Client Setup

The Expo client stores sessions in SecureStore instead of cookies:

// apps/mobile/src/lib/auth.ts
import { createAuthClient } from "better-auth/react";
import { expoClient } from "@better-auth/expo/client";
import { emailOTPClient } from "better-auth/client/plugins";
import * as SecureStore from "expo-secure-store";
 
const API_URL = process.env.EXPO_PUBLIC_API_URL ?? "http://localhost:3001";
 
export const authClient = createAuthClient({
  baseURL: API_URL,
  plugins: [
    expoClient({
      scheme: "eden",           // Your app's URL scheme
      storagePrefix: "eden",    // Prefix for SecureStore keys
      storage: SecureStore,     // Use Expo SecureStore
    }),
    emailOTPClient(),
  ],
});
 
export const { signIn, signOut, useSession, getSession } = authClient;

The expoClient plugin:

  • Stores session tokens in encrypted SecureStore
  • Handles deep link OAuth callbacks
  • Attaches tokens to requests automatically

API Client with Auth Headers

For authenticated API calls, pass cookies from SecureStore:

// apps/mobile/src/lib/api.ts
import { treaty } from "@elysiajs/eden";
import * as SecureStore from "expo-secure-store";
import type { App } from "@eden/api";
 
const API_URL = process.env.EXPO_PUBLIC_API_URL ?? "http://localhost:3001";
const COOKIE_KEY = "eden_cookie";
 
async function getAuthCookies(): Promise<Record<string, string>> {
  try {
    const cookiesJson = await SecureStore.getItemAsync(COOKIE_KEY);
    if (!cookiesJson) return {};
 
    const cookiesObj = JSON.parse(cookiesJson);
    const cookieString = Object.entries(cookiesObj)
      .map(([name, cookie]: [string, any]) => `${name}=${cookie.value}`)
      .join("; ");
 
    return { Cookie: cookieString };
  } catch {
    return {};
  }
}
 
export const api = treaty<App>(API_URL, {
  fetch: {
    credentials: "include",
  },
  headers: async () => {
    return await getAuthCookies();
  },
});

Email OTP Flow

The email OTP flow works identically on web and mobile:

// Mobile login screen
import { authClient } from "../lib/auth";
 
function LoginScreen() {
  const [email, setEmail] = useState("");
  const [code, setCode] = useState("");
  const [step, setStep] = useState<"email" | "code">("email");
 
  const requestCode = async () => {
    const result = await authClient.signIn.emailOtp({
      email,
      callbackURL: "/",
    });
    
    if (result.data?.success) {
      setStep("code");
    }
  };
 
  const verifyCode = async () => {
    const result = await authClient.signIn.emailOtp({
      email,
      otp: code,
    });
    
    if (result.data?.session) {
      // User is now logged in
      router.replace("/(tabs)/home");
    }
  };
 
  if (step === "email") {
    return (
      <View>
        <TextInput
          value={email}
          onChangeText={setEmail}
          placeholder="Enter your email"
          keyboardType="email-address"
          autoCapitalize="none"
        />
        <Button title="Send Code" onPress={requestCode} />
      </View>
    );
  }
 
  return (
    <View>
      <Text>Enter the code sent to {email}</Text>
      <TextInput
        value={code}
        onChangeText={setCode}
        placeholder="000000"
        keyboardType="number-pad"
        maxLength={6}
      />
      <Button title="Verify" onPress={verifyCode} />
    </View>
  );
}

OAuth requires extra setup for mobile:

1. Configure app.json

// apps/mobile/app.json
{
  "expo": {
    "scheme": "eden",
    "ios": {
      "bundleIdentifier": "com.yourcompany.eden"
    },
    "android": {
      "package": "com.yourcompany.eden",
      "intentFilters": [
        {
          "action": "VIEW",
          "autoVerify": true,
          "data": [
            { "scheme": "eden" }
          ],
          "category": ["BROWSABLE", "DEFAULT"]
        }
      ]
    }
  }
}

2. Implement OAuth Flow

// apps/mobile/app/login.tsx
import * as WebBrowser from "expo-web-browser";
import * as Linking from "expo-linking";
import { authClient } from "../lib/auth";
 
// Complete any pending auth sessions when app opens
WebBrowser.maybeCompleteAuthSession();
 
function OAuthButtons() {
  const handleGoogleSignIn = async () => {
    // Get the OAuth URL from Better Auth
    const result = await authClient.signIn.social({
      provider: "google",
      callbackURL: Linking.createURL("/"), // Deep link back to app
    });
 
    if (result.data?.url) {
      // Open in-app browser
      const authResult = await WebBrowser.openAuthSessionAsync(
        result.data.url,
        Linking.createURL("/")
      );
 
      if (authResult.type === "success") {
        // Session is now stored in SecureStore
        // Navigate to authenticated area
        router.replace("/(tabs)/home");
      }
    }
  };
 
  return (
    <View>
      <Button title="Continue with Google" onPress={handleGoogleSignIn} />
    </View>
  );
}
// apps/mobile/app/_layout.tsx
import { useEffect } from "react";
import * as Linking from "expo-linking";
import { authClient } from "../lib/auth";
 
export default function RootLayout() {
  useEffect(() => {
    // Handle deep link when app opens
    const handleDeepLink = async (event: { url: string }) => {
      const url = new URL(event.url);
      
      // Let Better Auth handle the callback
      if (url.pathname.includes("callback")) {
        await authClient.signIn.handleCallback(event.url);
      }
    };
 
    // Listen for incoming links
    const subscription = Linking.addEventListener("url", handleDeepLink);
    
    // Check for initial link (app opened via deep link)
    Linking.getInitialURL().then((url) => {
      if (url) handleDeepLink({ url });
    });
 
    return () => subscription.remove();
  }, []);
 
  return <Slot />;
}

Session Management

Use the session hook for auth state:

// apps/mobile/app/(tabs)/_layout.tsx
import { Redirect, Tabs } from "expo-router";
import { useSession } from "../lib/auth";
 
export default function TabLayout() {
  const { data: session, isPending } = useSession();
 
  if (isPending) {
    return <LoadingScreen />;
  }
 
  if (!session) {
    return <Redirect href="/login" />;
  }
 
  return (
    <Tabs>
      <Tabs.Screen name="home" />
      <Tabs.Screen name="profile" />
    </Tabs>
  );
}

Sign Out

Signing out clears both local storage and server session:

import { authClient } from "../lib/auth";
 
async function handleSignOut() {
  await authClient.signOut();
  router.replace("/login");
}

Web Client (for comparison)

The web client is simpler—cookies just work:

// apps/web/src/lib/auth.ts
import { createAuthClient } from "better-auth/react";
import { emailOTPClient } from "better-auth/client/plugins";
 
export const authClient = createAuthClient({
  baseURL: import.meta.env.VITE_API_URL,
  plugins: [emailOTPClient()],
});
 
export const { signIn, signOut, useSession, getSession } = authClient;

No SecureStore, no deep links—just HTTP-only cookies handled automatically.

Troubleshooting

1. CORS Errors on Mobile

Ensure trustedOrigins includes your app's deep link scheme:

trustedOrigins: [
  "eden://",
  "exp://",
  // ...
]

Mobile in-app browsers don't persist cookies. Enable:

account: {
  skipStateCookieCheck: true,
}

3. Session Not Persisting

Check that SecureStore is working:

const stored = await SecureStore.getItemAsync("eden_cookie");
console.log("Stored session:", stored);

Test deep links with:

# iOS
npx uri-scheme open eden://callback --ios
 
# Android
npx uri-scheme open eden://callback --android

Architecture Summary

Wrapping Up

Better Auth's Expo plugin eliminates the complexity of cross-platform auth. You write one server configuration and get:

  • Web: Cookie-based sessions, standard OAuth
  • Mobile: SecureStore sessions, deep link OAuth

The key insight is that Better Auth abstracts the storage mechanism—whether it's browser cookies or encrypted device storage, your app code stays the same.

Check out the Authentication guide for more details on the full auth flow.

Ready to build with Eden Stack?

One-time payment. Full source code. No lock-in.

View pricing