All posts
React Native Better Auth Authentication Google Sign-In Apple Sign-In

Native Google & Apple Sign-In with Better Auth in Bare React Native

Most Better Auth mobile guides assume Expo or browser-based OAuth redirects. Here’s how to get the native Google and Apple bottom sheets working in a bare React Native app using the ID token exchange pattern.

8 min read
Native Google & Apple Sign-In with Better Auth in Bare React Native

Most tutorials for Better Auth on mobile either assume you’re using Expo or rely on browser-based OAuth redirects. In a bare React Native CLI project, those redirects feel clunky. They bounce users to an external browser or system modal and trigger jarring deep links.

Users expect the native OS sheet: one tap, Face ID / Touch ID, or Google Play Services. This post is the blueprint for getting there with Better Auth using the native ID token exchange pattern, without Expo.

How it works

Instead of running the whole OAuth flow through a browser, the app asks the OS for a signed ID token and hands it to your backend:

  1. The app opens the native prompt using @react-native-google-signin/google-signin or @invertase/react-native-apple-authentication.
  2. The provider returns a signed ID token (a JWT).
  3. The app forwards it to Better Auth with authClient.signIn.social.
  4. The server verifies the token’s signature and audience (aud), finds or creates the user, and returns a session.

1. Backend: Better Auth server

Mobile apps work best with bearer tokens rather than cookies, so enable the bearer() plugin and configure the google and apple providers.

The multi-client ID trap. Google issues ID tokens whose audience matches either your Web client ID or your iOS/Android client ID. If the server only knows the web one, iOS sign-ins fail verification. Pass an array of client IDs.

// server/auth.ts
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { bearer } from "better-auth/plugins";
import prisma from "./db";

export const auth = betterAuth({
  database: prismaAdapter(prisma, { provider: "postgresql" }), // or mysql, sqlite, mongodb
  baseURL: process.env.BETTER_AUTH_URL || "https://api.yourdomain.com",

  plugins: [bearer()],

  socialProviders: {
    google: {
      // Both Web and iOS ID tokens pass audience validation
      clientId: [
        process.env.GOOGLE_WEB_CLIENT_ID!,
        process.env.GOOGLE_IOS_CLIENT_ID || "",
      ].filter(Boolean) as any,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
      mapProfileToUser: (profile) => ({
        name: profile.name || profile.given_name || "New User",
        email: profile.email,
        image: profile.picture,
      }),
    },
    apple: {
      clientId: process.env.APPLE_CLIENT_ID!, // Service ID
      clientSecret: process.env.APPLE_CLIENT_SECRET || "",
      // Native iOS tokens use the app's bundle ID as the audience
      appBundleIdentifier: process.env.APPLE_APP_BUNDLE_ID,
      mapProfileToUser: (profile) => ({
        name: profile.name || "Apple User",
        email: profile.email || `${profile.sub}@apple.placeholder.yourdomain.com`,
      }),
    },
  },

  account: {
    accountLinking: {
      enabled: true,
      trustedProviders: ["google", "apple"],
    },
  },
});

2. Client: configuring Better Auth for bare React Native

React Native has no browser cookie jar for arbitrary fetch requests. Give createAuthClient a fetch interceptor that attaches the bearer token and captures the one the server returns.

npm install better-auth @react-native-google-signin/google-signin @invertase/react-native-apple-authentication @react-native-async-storage/async-storage

iOS: run cd ios && pod install, and enable the Sign in with Apple capability in Xcode under Signing & Capabilities.

// src/lib/auth-client.js
import { createAuthClient } from 'better-auth/client';
// Use Keychain / EncryptedSharedPreferences for production token storage
import { SecureStorage } from '@/utils/secureStorage';

export const authClient = createAuthClient({
  baseURL: 'https://api.yourdomain.com',

  fetchOptions: {
    async onRequest(ctx) {
      const token = await SecureStorage.getToken();
      if (token) {
        ctx.headers.set('authorization', `Bearer ${token}`);
      }
      return ctx;
    },
    async onSuccess(ctx) {
      // The bearer plugin returns the session token in this header
      const authToken = ctx.response?.headers?.get?.('set-auth-token');
      if (authToken) {
        await SecureStorage.saveToken(authToken);
      }
    },
  },
});

export default authClient;

3. A unified socialSignIn function

One module handles both providers: get the token natively, exchange it with Better Auth, then load the session.

// src/lib/social.js
import { GoogleSignin, isErrorWithCode, statusCodes } from '@react-native-google-signin/google-signin';
import { appleAuth } from '@invertase/react-native-apple-authentication';
import { authClient } from '@/lib/auth-client';
import { useUserStore } from '@/store/userStore';

GoogleSignin.configure({
  webClientId: 'YOUR_GOOGLE_WEB_CLIENT_ID.apps.googleusercontent.com',
  iosClientId: 'YOUR_GOOGLE_IOS_CLIENT_ID.apps.googleusercontent.com',
});

export const CANCELLED = 'CANCELLED';

// Step 1: get the ID token from the native provider
async function getProviderToken(provider) {
  if (provider === 'google') {
    await GoogleSignin.hasPlayServices();
    const result = await GoogleSignin.signIn();

    // Handles API differences across library versions
    if (result.type !== undefined) {
      if (result.type !== 'success') throw new Error(CANCELLED);
      return {
        token: result.data.idToken,
        accessToken: result.data.serverAuthCode || result.data.accessToken,
      };
    }
    return {
      token: result.idToken,
      accessToken: result.serverAuthCode || result.accessToken,
    };
  }

  if (provider === 'apple') {
    const result = await appleAuth.performRequest({
      requestedOperation: appleAuth.Operation.LOGIN,
      requestedScopes: [appleAuth.Scope.FULL_NAME, appleAuth.Scope.EMAIL],
    });

    return {
      token: result.identityToken,
      nonce: result.nonce,
      fullName: result.fullName,
    };
  }

  throw new Error(`Unsupported provider: ${provider}`);
}

// Step 2: full sign-in pipeline
export async function socialSignIn(provider) {
  try {
    const { token, accessToken, nonce, fullName } = await getProviderToken(provider);

    // idToken must be an object with a `token` property
    const response = await authClient.signIn.social({
      provider,
      idToken: {
        token,
        ...(accessToken && { accessToken }),
        ...(nonce && { nonce }), // needed for Apple verification
      },
    });

    if (response.error) {
      throw new Error(response.error.message || 'Authentication failed');
    }

    const sessionData = await authClient.getSession();
    if (!sessionData?.data?.user || !sessionData?.data?.session) {
      throw new Error('Failed to create session');
    }
    const { user, session } = sessionData.data;

    // Apple only sends the name on the FIRST authorization
    if (fullName?.givenName) {
      try {
        await authClient.updateUser({
          name: `${fullName.givenName} ${fullName.familyName ?? ''}`.trim(),
        });
      } catch (e) {
        console.warn('Could not update user name from Apple credentials', e);
      }
    }

    await useUserStore.getState().saveUser(user, session);
    return { user, session };
  } catch (error) {
    if (isErrorWithCode(error)) {
      if (error.code === statusCodes.SIGN_IN_CANCELLED) throw new Error(CANCELLED);
      if (error.code === statusCodes.IN_PROGRESS) throw new Error('Sign-in already in progress');
      if (error.code === statusCodes.PLAY_SERVICES_NOT_AVAILABLE) {
        throw new Error('Google Play Services not available');
      }
    }
    // Apple's cancellation code
    if (error?.code === '1001') throw new Error(CANCELLED);
    throw error;
  }
}

4. Gotchas worth knowing

idToken must be an object

Passing a plain string fails with validation errors.

// ❌ Wrong
await authClient.signIn.social({ provider: 'google', idToken: token });

// ✅ Right
await authClient.signIn.social({
  provider: 'google',
  idToken: { token, ...(accessToken && { accessToken }) },
});

Apple only sends the name once

The user’s real name arrives in result.fullName on the first login only. After that it’s null. Check fullName?.givenName and call authClient.updateUser(...) immediately, or you’ll end up with a blank display name.

Apple cancellation is error 1001

When the user dismisses the Apple sheet, you get code 1001. Don’t show an “Authentication failed” alert. Dismiss silently.

Web client ID vs. iOS client ID

Use the Web OAuth 2.0 client ID as webClientId in GoogleSignin.configure. On the server, list both client IDs so verification passes on every platform.

5. Wiring it to the UI

// src/screens/LoginScreen.jsx
import React, { useState } from 'react';
import { View, Button, Platform, ActivityIndicator, Alert } from 'react-native';
import { socialSignIn, CANCELLED } from '@/lib/social';

export default function LoginScreen() {
  const [loading, setLoading] = useState(false);

  const handleSocialLogin = async (provider) => {
    setLoading(true);
    try {
      await socialSignIn(provider);
      // Navigation happens via the store listener
    } catch (error) {
      if (error.message !== CANCELLED) {
        Alert.alert('Sign-In Error', error.message || 'Something went wrong');
      }
    } finally {
      setLoading(false);
    }
  };

  return (
    <View style={{ flex: 1, justifyContent: 'center', padding: 20 }}>
      {loading ? (
        <ActivityIndicator size="large" />
      ) : (
        <>
          <Button title="Continue with Google" onPress={() => handleSocialLogin('google')} />
          {Platform.OS === 'ios' && (
            <Button title="Sign in with Apple" onPress={() => handleSocialLogin('apple')} />
          )}
        </>
      )}
    </View>
  );
}

Wrapping up

The ID token exchange pattern gives you the native feel users expect without fighting redirects:

  1. Native libraries handle OS-level authentication and biometrics.
  2. Better Auth verifies the ID token on your backend.
  3. Bearer plugin + secure storage keeps sessions alive across app restarts.
Back to writing