Google Sign In looks straightforward until it isn't. The library installs fine, the button renders, then you hit a cryptic DEVELOPER_ERROR on Android or a silent failure on iOS with no stack trace. I've set this up across a dozen React Native projects and the bugs are almost always the same three things: wrong SHA-1 fingerprint in Firebase, missing URL scheme in Info.plist, or a mismatched OAuth client ID. This guide eliminates all of them.
Step 1 — Firebase project and OAuth client setup
Go to the Firebase console → create or open your project → add an Android app with your exact package name → download google-services.json and place it at android/app/google-services.json. For iOS, add an iOS app with your bundle ID → download GoogleService-Info.plist → drag it into Xcode under the project root (not a subfolder). The OAuth client IDs are embedded in these files — do not hardcode them manually. For Android, you must register both your debug and release SHA-1 fingerprints or sign-in will silently fail in release builds.
# Debug keystore (for development) keytool -list -v \ -keystore ~/.android/debug.keystore \ -alias androiddebugkey \ -storepass android \ -keypass android # Release keystore (for production — use your actual keystore path) keytool -list -v \ -keystore ./android/app/release.keystore \ -alias your-key-alias
Step 2 — Install and link
npm install @react-native-google-signin/google-signin # android/build.gradle — add inside dependencies classpath 'com.google.gms:google-services:4.4.1' # android/app/build.gradle — add at bottom apply plugin: 'com.google.gms.google-services' # iOS cd ios && pod install
Step 3 — iOS URL scheme (the trap everyone falls into)
Open GoogleService-Info.plist and find the REVERSED_CLIENT_ID value — it looks like com.googleusercontent.apps.xxxxxxxxxx-xxxx. Now open Xcode → your target → Info tab → URL Types → add a new entry → set the URL Scheme to that exact reversed client ID value. Without this, Google's auth callback cannot return to your app on iOS and sign-in will hang indefinitely or silently fail. This is the single most common iOS setup mistake.
Step 4 — Configure and implement sign in
import {
GoogleSignin,
GoogleSigninButton,
statusCodes,
} from '@react-native-google-signin/google-signin';
import { useEffect } from 'react';
// Call once at app startup (e.g. in App.jsx)
GoogleSignin.configure({
// Found in GoogleService-Info.plist as CLIENT_ID
iosClientId: 'YOUR_IOS_CLIENT_ID.apps.googleusercontent.com',
// Scopes beyond profile + email
scopes: ['profile', 'email'],
// Required if you need offline access / refresh tokens on backend
offlineAccess: true,
});
export async function signInWithGoogle() {
try {
await GoogleSignin.hasPlayServices({ showPlayServicesUpdateDialog: true });
const userInfo = await GoogleSignin.signIn();
const { idToken } = await GoogleSignin.getTokens();
// Send idToken to your backend for verification
// Never use the userInfo directly to create sessions
return { userInfo, idToken };
} catch (error) {
if (error.code === statusCodes.SIGN_IN_CANCELLED) {
return null; // user backed out — not an error
} else if (error.code === statusCodes.IN_PROGRESS) {
return null; // already signing in
} else if (error.code === statusCodes.PLAY_SERVICES_NOT_AVAILABLE) {
throw new Error('Google Play Services not available');
}
throw error;
}
}Step 5 — Silent sign-in for returning users
Don't make returning users tap the button every time. On app start, call GoogleSignin.signInSilently() — it restores the previous session using a cached refresh token without any UI. If it throws a statusCodes.SIGN_IN_REQUIRED error, show the sign-in button. This is also where you refresh the idToken, since Google's idTokens expire after 1 hour. Always call getTokens() after signInSilently() to get a fresh idToken before hitting your backend.
import { useEffect } from 'react';
import { GoogleSignin, statusCodes } from '@react-native-google-signin/google-signin';
import { useDispatch } from 'react-redux';
import { setUser, clearUser } from '../store/authSlice';
export function useSilentSignIn() {
const dispatch = useDispatch();
useEffect(() => {
async function trySilentSignIn() {
try {
const userInfo = await GoogleSignin.signInSilently();
const { idToken } = await GoogleSignin.getTokens(); // fresh token
dispatch(setUser({ userInfo, idToken }));
} catch (error) {
if (error.code === statusCodes.SIGN_IN_REQUIRED) {
dispatch(clearUser()); // expected — show login screen
}
}
}
trySilentSignIn();
}, []);
}Backend token verification
On your server, verify the idToken using Google's tokeninfo endpoint or the google-auth-library. Never create a user session based on the client-side userInfo object alone — it can be spoofed. Verify the aud field matches your client ID and the email_verified field is true. If you passed offlineAccess: true during configure, the sign-in response also includes a serverAuthCode — exchange this on your backend for a refresh token, which lets your server call Google APIs on the user's behalf even when the app is closed.
import { OAuth2Client } from 'google-auth-library';
const client = new OAuth2Client(process.env.GOOGLE_CLIENT_ID);
export async function verifyGoogleToken(idToken) {
const ticket = await client.verifyIdToken({
idToken,
audience: process.env.GOOGLE_CLIENT_ID,
});
const payload = ticket.getPayload();
if (!payload.email_verified) {
throw new Error('Email not verified by Google');
}
return {
googleId: payload.sub,
email: payload.email,
name: payload.name,
avatar: payload.picture,
};
}Common DEVELOPER_ERROR causes
DEVELOPER_ERROR on Android almost always means one of three things: the SHA-1 fingerprint registered in Firebase doesn't match the keystore you're building with (debug vs release), the package name in Firebase doesn't exactly match applicationId in build.gradle, or the google-services.json file is outdated and doesn't reflect recent Firebase console changes. Download a fresh copy of google-services.json after any Firebase console change — it's cached locally and won't auto-update.
Checklist before going to production: register both debug AND release SHA-1 fingerprints in Firebase, add the REVERSED_CLIENT_ID URL scheme in Xcode, verify idTokens on the server — never on the client, handle token expiry with signInSilently + getTokens() on app resume, and test sign-out + sign-in again to confirm the flow works after credential revocation.