Back to Blog
Building Native Google Sign-In for Expo Without Third-Party Plugins
Engineering
Tutorials

Building Native Google Sign-In for Expo Without Third-Party Plugins

JANUARY 30, 2025

When building TrueBeep, we knew social login was non-negotiable. Users expect quick, frictionless authentication, and Google Sign-In is table stakes for any modern mobile app. But as we evaluated the available solutions for React Native and Expo, we kept running into the same concerns.

First, we looked at expo-auth-session, Expo's official OAuth solution. It works, but uses web-based redirects with custom URI schemes. That raised immediate security red flags URI schemes can be hijacked by malicious apps on the same device.

Then there were third-party options like @react-native-google-signin/google-signin, which offers a free version (using Google's deprecated but functional legacy Android SDK) and paid tiers starting at $79/year for individuals or $199/year for teams. The paid version includes modern Android SDK, advanced security features, and ongoing updates. For some projects, this is a reasonable investment. For us, building on critical auth infrastructure where we wanted complete control and modern SDK support without recurring costs made building our own solution worthwhile.

Beyond the options themselves, we faced real implementation challenges: configuring redirect URIs across development, staging, and production environments was complex. iOS 18 introduced breaking changes that affected some authentication flows. And we needed a solution that worked seamlessly with EAS builds while gracefully degrading in Expo Go during development.

After weighing security, control, and long-term maintainability, we decided to build our own native Google Sign-In implementation using Expo's custom native modules. This gave us direct access to Google's official SDKs on both platforms, eliminated web redirect vulnerabilities, and put us in full control of the authentication flow.

In this guide, I'll walk you through how we built a production-ready, native Google authentication system that:

  • Works directly with Google's native SDKs (no web redirects)

  • Eliminates URI scheme vulnerabilities

  • Gracefully handles Expo Go vs. production builds

  • Supports iOS 18 with compatibility fixes

  • Gives you complete control over your auth implementation

Prerequisites: You'll need an Expo app using EAS builds, a Google Cloud project, and some comfort with TypeScript, Kotlin, and Swift. Don't worry if you're not an expert in the native languages we'll walk through everything step by step.

The Problem with Web-Based OAuth

Most React Native OAuth solutions rely on web redirects: the app opens a browser, the user authenticates, and Google redirects back using a custom URI scheme (like myapp://callback).

But there's a critical security issue: custom URI schemes can be hijacked. Any malicious app on the device can register the same scheme and intercept your tokens. Plus, managing redirect URIs across different environments is complex.

The Native Solution

Native Google Sign-In works differently. Instead of web redirects, authentication happens entirely within Google's native process:

Android: Google Play Services handles authentication in a secure overlay, returning the ID token through Android's intent system.

iOS: The Google Sign-In SDK presents Apple's native authentication sheet with a direct callback.

The benefits? No redirect URIs, no URI hijacking, package verification via SHA-1 fingerprints (Android) or bundle identifiers (iOS).

Architecture Overview

Our implementation has three layers:

Login.tsx (React Native UI)

         ↓

googleAuth.service.ts (TypeScript service layer)

         ↓

expo-google-signin/ (Custom native module)
    ├── Android (Kotlin + Google Play Services)
    ├── iOS (Swift + Google Sign-In SDK)
    └── JavaScript bridge (Expo Modules API)

The service layer handles runtime detection, gracefully falling back in Expo Go while enabling full functionality in EAS builds.

Step 1: Setting Up Google Cloud Console

First, we need to configure Google Cloud. You'll create three OAuth clients one for Android, one for iOS, and one for web (used for token requests).

Android OAuth Client

  1. Go to Google Cloud ConsoleAPIs & ServicesCredentials

  2. Click Create CredentialsOAuth 2.0 Client ID

  3. Choose Android as application type

  4. Package name: Your app's bundle identifier (e.g., com.myapp.mobile)

  5. SHA-1 fingerprint: Get this from your EAS build logs

Here's where native authentication shines: No redirect URI needed. Google verifies your app using the package name and SHA-1 fingerprint instead. That's process-level security.

iOS OAuth Client

  1. Create another OAuth client, this time choosing iOS

  2. Bundle identifier: Same as your app (e.g., com.myapp.mobile)

  3. Again, no redirect URI required

Web OAuth Client

Create one more client as Web application. This is used internally by the SDKs for ID token requests you won't use it for actual web redirects.

Grab all three client IDs. We'll need them in our environment variables.

Step 2: Creating the Custom Native Module

Expo's custom native modules live in a modules/ directory. Let's create the structure:

mkdir -p modules/expo-google-signin/android/src/main/java/expo/modules/googlesignin
mkdir -p modules/expo-google-signin/ios/ExpoGoogleSignIn

Android Implementation (Kotlin)

Here's our Android module using Google Play Services. This is the real deal production-grade code that handles the sign-in flow, activity results, and error cases:

ExpoGoogleSignInModule.kt
package expo.modules.googlesignin

import com.google.android.gms.auth.api.signin.GoogleSignIn
import com.google.android.gms.auth.api.signin.GoogleSignInClient
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
import com.google.android.gms.common.api.ApiException
import expo.modules.kotlin.Promise
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition

class ExpoGoogleSignInModule : Module() {
  private var googleSignInClient: GoogleSignInClient? = null
  private var signInPromise: Promise? = null

  override fun definition() = ModuleDefinition {
    Name("ExpoGoogleSignIn")

    // Configure Google Sign-In with your OAuth client ID
    AsyncFunction("configure") { webClientId: String, promise: Promise ->
      try {
        val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
          .requestIdToken(webClientId)  // This is key requests an ID token for backend auth
          .requestEmail()
          .build()
        googleSignInClient = GoogleSignIn.getClient(appContext.reactContext!!, gso)
        promise.resolve(true)
      } catch (e: Exception) {
        promise.reject("CONFIGURE_ERROR", "Failed to configure: ${e.message}", e)
      }
    }

    // Launch the native Google Sign-In flow
    AsyncFunction("signIn") { promise: Promise ->
      val client = googleSignInClient
      if (client == null) {
        promise.reject("NOT_CONFIGURED", "Call configure() first")
        return@AsyncFunction
      }

      // Clean state by signing out any previous session
      client.signOut()
      signInPromise = promise
      val signInIntent = client.signInIntent
      appContext.currentActivity?.startActivityForResult(signInIntent, 9001)
    }

    // Handle the result from Google's authentication activity
    OnActivityResult { activity, payload ->
      if (payload.requestCode == 9001) {
        try {
          val task = GoogleSignIn.getSignedInAccountFromIntent(payload.data)
          val account = task.getResult(ApiException::class.java)

          // Return ID token and user info to JavaScript
          signInPromise?.resolve(mapOf(
            "idToken" to account.idToken,
            "user" to mapOf(
              "id" to account.id,
              "email" to account.email,
              "name" to account.displayName
            )
          ))
        } catch (e: ApiException) {
          signInPromise?.reject("SIGN_IN_FAILED", e.message, e)
        }
        signInPromise = null
      }
    }
  }
}

The configure function sets up Google Play Services, signIn launches Google's native UI via Android intent, and OnActivityResult handles the callback. We request an idToken (signed JWT for backend verification) rather than an access token.

iOS Implementation (Swift)

The iOS side uses Google's Sign-In SDK. Here's where it gets interesting we had to add several iOS 18-specific fixes after running into timing and view controller issues:

ExpoGoogleSignInModule.swift
import ExpoModulesCore
import GoogleSignIn
import UIKit

public class ExpoGoogleSignInModule: Module {
  public func definition() -> ModuleDefinition {
    Name("ExpoGoogleSignIn")

    AsyncFunction("configure") { (clientId: String, promise: Promise) in
      // iOS 18 Fix: Configuration must happen on main thread
      DispatchQueue.main.async {
        guard clientId.contains("apps.googleusercontent.com") else {
          promise.reject("CONFIGURE_ERROR", "Invalid client ID format")
          return
        }

        let config = GIDConfiguration(clientID: clientId)
        GIDSignIn.sharedInstance.configuration = config
        promise.resolve(true)
      }
    }

    AsyncFunction("signIn") { (promise: Promise) in
      // CRITICAL: Ensure main thread (iOS 18 requirement)
      guard Thread.isMainThread else {
        DispatchQueue.main.async {
          self.performSignIn(promise: promise)
        }
        return
      }
      performSignIn(promise: promise)
    }
  }

  private func performSignIn(promise: Promise) {
    guard let presentingController = getPresentingViewController() else {
      promise.reject("NO_VIEW_CONTROLLER", "Can't find view controller")
      return
    }

    // Call Google's native sign-in
    GIDSignIn.sharedInstance.signIn(withPresenting: presentingController) { result, error in
      if let error = error {
        promise.reject("SIGN_IN_FAILED", error.localizedDescription)
        return
      }

      guard let user = result?.user,
            let idToken = user.idToken?.tokenString else {
        promise.reject("NO_TOKEN", "No ID token received")
        return
      }

      promise.resolve([
        "idToken": idToken,
        "user": [
          "id": user.userID ?? "",
          "email": user.profile?.email ?? "",
          "name": user.profile?.name ?? ""
        ]
      ])
    }
  }
}

iOS 18 gotcha: Google's SDK requires main thread execution and careful view controller state management. The getPresentingViewController() helper (in our full implementation) uses the modern UIWindowScene API with fallbacks for older iOS versions.

Step 3: The JavaScript Bridge

Expo's Modules API handles most of the TypeScript binding for us, but we need a thin wrapper for type safety:

ExpoGoogleSignIn.ts
import ExpoGoogleSignInModule from './ExpoGoogleSignInModule';

export interface GoogleSignInResult {
  idToken: string;
  user: {
    id: string;
    email: string;
    name: string;
  };
}

export async function configure(clientId: string): Promise<boolean> {
  return await ExpoGoogleSignInModule.configure(clientId);
}

export async function signIn(): Promise<GoogleSignInResult> {
  return await ExpoGoogleSignInModule.signIn();
}

export async function signOut(): Promise<void> {
  await ExpoGoogleSignInModule.signOut();
}

Clean and simple. TypeScript gives us compile-time safety, and the native modules handle the heavy lifting.

Step 4: The Service Layer

Here's where we add the magic runtime detection and graceful fallbacks. This service automatically detects whether the native module is available:

googleAuth.service.ts
import { Platform } from 'react-native';
import { config } from '../../config/env';

let ExpoGoogleSignIn: any;

try {
  ExpoGoogleSignIn = require('../../modules/expo-google-signin');
} catch (e) {
  // Module not available (Expo Go or build issue)
  ExpoGoogleSignIn = null;
}

async function checkAvailability(): Promise<boolean> {
  if (!ExpoGoogleSignIn) return false;
  // Verify the module actually has the methods we need
  return typeof ExpoGoogleSignIn.configure === 'function';
}

async function configure(): Promise<boolean> {
  const isAvailable = await checkAvailability();
  if (!isAvailable) {
    console.log('[GoogleSignIn] Native module not available');
    return false;
  }

  // Use platform-specific client ID
  const clientId =
    Platform.OS === 'ios'
      ? config.google.iosClientId
      : config.google.androidClientId;

  try {
    await ExpoGoogleSignIn.configure(clientId);
    return true;
  } catch (error) {
    console.error('[GoogleSignIn] Configuration failed:', error);
    return false;
  }
}

async function signIn(): Promise<GoogleSignInResult> {
  const isAvailable = await checkAvailability();
  if (!isAvailable) {
    return {
      success: false,
      error: 'Google Sign-In not available in this environment',
    };
  }

  try {
    const result = await ExpoGoogleSignIn.signIn();
    return {
      success: true,
      idToken: result.idToken,
    };
  } catch (error: any) {
    return {
      success: false,
      error: error.message || 'Sign-in failed',
    };
  }
}

export const googleAuthService = {
  isAvailable: checkAvailability,
  configure,
  signIn,
};

This service layer checks availability at runtime in Expo Go, native modules aren't compiled, so we return false and hide the button. In EAS builds, everything works.

Step 5: Integrating with Your Login Screen

Finally, let's wire this up to a React component. Here's how we conditionally show the Google Sign-In button:

Login.tsx
import React, { useState, useEffect } from 'react';
import { View } from 'react-native';
import { googleAuthService } from '../services/auth/googleAuth.service';
import { GoogleSignInButton } from './GoogleSignInButton';

export const Login: React.FC = () => {
  const [isGoogleAvailable, setIsGoogleAvailable] = useState(false);

  useEffect(() => {
    // Check if Google Sign-In is available when component mounts
    async function checkGoogleAuth() {
      const available = await googleAuthService.isAvailable();
      setIsGoogleAvailable(available);
      if (available) {
        // Configure the native module
        await googleAuthService.configure();
      }
    }
    checkGoogleAuth();
  }, []);

  const handleGoogleSignIn = async () => {
    const result = await googleAuthService.signIn();
    if (result.success && result.idToken) {
      // Send the ID token to your backend for verification
      await fetch('https://api.yourapp.com/auth/google', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ idToken: result.idToken })
      });
    } else {
      console.error('Google Sign-In failed:', result.error);
    }
  };

  return (
    <View>
      {/* Only show Google button when native module is available */}
      {isGoogleAvailable && (
        <GoogleSignInButton onPress={handleGoogleSignIn} />
      )}
      {/* Always show email/password as fallback */}
      {/* ...your email login UI... */}
    </View>
  );
};

In Expo Go, the button doesn't appear. In EAS builds, it appears automatically no code changes needed.

Testing and Verification

In Expo Go (Development)

npx expo start

Expected behavior:

  • App loads without crashes ✅

  • Google Sign-In button is hidden ✅

  • Email/OTP login works normally ✅

In EAS Build (Production)

eas build --profile preview --platform android

Expected behavior:

  • Google Sign-In button appears ✅

  • Tapping it shows Google's native authentication UI ✅

  • After authentication, you receive an ID token ✅

  • Token is sent to your backend for verification ✅

Troubleshooting:

  • "Developer Error" on Android → Wrong SHA-1 fingerprint (check Google Console vs. EAS build logs)

  • "No suitable view controller" on iOS → Main thread issue (should be fixed by our code)

  • Button doesn't appear → Module not available (expected in Expo Go)

Going Further

Ready to take this to production? Here are some next steps:

  • Backend verification: Verify ID tokens on your server using Google's auth libraries (google-auth-library for Node.js)

  • Error handling: Add retry logic for network failures and clear error messages for users

  • Analytics: Track sign-in success rates and failure reasons to prioritize improvements

  • Silent sign-in: Implement signInSilently() to authenticate returning users automatically

Final Thoughts

Building your own native authentication with Expo's custom modules is more approachable than you'd think. The iOS 18 compatibility challenges taught us about threading requirements, view controller lifecycle, and SDK quirks details you only learn by building things yourself. When the next iOS update breaks something, we can fix it immediately instead of waiting for a third-party maintainer.

Is this approach right for every app? If you're serious about security, want full control over critical infrastructure, or just enjoy understanding how things work under the hood, going native is incredibly rewarding. Feel free to adapt our implementation for your own projects and let us know how it goes!

Ready to talk to your customers smarter, everywhere?

Start free with 100 customers included. No credit card required.