Multi-Platform Authentication with Better Auth and Expo
January 25, 2025 · Eden Stack Team
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:
| Aspect | Web | Mobile |
|---|---|---|
| Session storage | Cookies (HTTP-only) | SecureStore |
| OAuth callback | URL redirect | Deep link |
| Origin | Known domains | Device-specific |
| Token handling | Automatic (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:
expo()plugin: Adds mobile-specific endpoints and session handlingexpoOriginFix(): Rewritesexpo-originheader to properoriginfor CORStrustedOrigins: Accepts deep link schemes (eden://,exp://)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 with Deep Links
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>
);
}3. Handle Deep Link Callback
// 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://",
// ...
]2. OAuth State Cookie Errors
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);4. Deep Links Not Working
Test deep links with:
# iOS
npx uri-scheme open eden://callback --ios
# Android
npx uri-scheme open eden://callback --androidArchitecture 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.