essential-google-signin
v1.0.0
Published
Native Google Sign-In for Expo using Android Credential Manager and iOS GoogleSignIn SDK
Maintainers
Readme
essential-google-signin
Native Google Sign-In for Expo, built on the Android Credential Manager API and the official GoogleSignIn SDK for iOS.
Both platforms return the same user object, built from the claims of the same ID token, whose audience is your web client ID — so one backend verification path covers iOS and Android.
Features
- Android Credential Manager (the modern replacement for
GoogleSignInClient) - Official GoogleSignIn SDK on iOS
- Silent sign-in / session restore on both platforms
- One set of error codes across platforms, including a distinct code for user cancellation
- Automatic native configuration through an Expo config plugin — no AppDelegate edits
- Fully typed, no runtime dependencies
- Web is not supported (native SDKs only), but the module loads and degrades predictably
Requirements
| | | | --- | --- | | Expo SDK | 54 or newer (developed and tested against SDK 57) | | iOS | 15.1+ | | Android | API 24+ (Android 7.0), with Google Play Services 23.08.15 or newer | | Web | Not supported — see Web |
Install
npx expo install essential-google-signinThis module needs custom native code. It works with expo prebuild / development builds, and does
not run in Expo Go.
Google Cloud setup
You need up to three OAuth 2.0 client IDs from the Google Cloud console, under APIs & Services → Credentials.
Web client ID — required on both platforms. Create a client of type Web application. Despite the name it has nothing to do with web support: Android signs in against it, and on iOS it becomes the ID token's audience so your backend has one value to verify.
iOS client ID — required for iOS. Create a client of type iOS with your bundle identifier.
Android client ID — optional. Create a client of type Android with your package name and signing certificate SHA-1:
cd android && ./gradlew signingReport # SHA1 under "Variant: debug"Registering that client is what lets Google match your app; the client ID string itself is never sent by Credential Manager, so passing it to the plugin is optional and only recorded for debugging.
Configuration
Add the plugin to your app config:
{
"expo": {
"plugins": [
[
"essential-google-signin",
{
"webClientId": "YOUR_WEB_CLIENT_ID.apps.googleusercontent.com",
"iosClientId": "YOUR_IOS_CLIENT_ID.apps.googleusercontent.com",
"androidClientId": "YOUR_ANDROID_CLIENT_ID.apps.googleusercontent.com"
}
]
]
}
}Then regenerate the native projects:
npx expo prebuild --cleanThe plugin writes the client IDs into AndroidManifest.xml, writes GIDClientID and
GIDServerClientID plus the reversed-client-ID URL scheme into Info.plist, and patches the
Podfile with :modular_headers => true for GoogleSignIn's Objective-C dependencies.
It does not modify your AppDelegate. The OAuth callback is handled by an
ExpoAppDelegateSubscriber registered by the module itself, which works with both the Swift
AppDelegate used since SDK 53 and the older Objective-C one.
Plugin options
| Option | Required | Notes |
| --- | --- | --- |
| webClientId | Yes | Type Web application. Becomes the ID token audience on both platforms. |
| iosClientId | On iOS | Type iOS. Required unless the project is Android-only. |
| androidClientId | No | Type Android. Recorded in the manifest; never sent at runtime. |
The plugin fails the build on a client ID that isn't a *.apps.googleusercontent.com value, or when
webClientId is accidentally set to the iOS or Android client ID — the usual cause of
BAD_AUTHENTICATION at runtime.
Usage
import EssentialGoogleSignin, {
GoogleSigninErrorCode,
getGoogleSigninErrorCode,
isCancelledError,
type GoogleUser,
} from "essential-google-signin";
import { useEffect, useState } from "react";
import { Button, Platform, Text, View } from "react-native";
export default function App() {
const [user, setUser] = useState<GoogleUser | null>(null);
useEffect(() => {
if (Platform.OS === "web") return;
(async () => {
await EssentialGoogleSignin.configure();
try {
// Signs a returning user back in with no UI, and refreshes their ID token.
setUser(await EssentialGoogleSignin.signInSilently());
} catch (error) {
// Nobody signed in yet — the expected first-launch outcome.
if (getGoogleSigninErrorCode(error) !== GoogleSigninErrorCode.NoCredential) {
throw error;
}
}
})();
}, []);
const signIn = async () => {
try {
setUser(await EssentialGoogleSignin.signIn());
} catch (error) {
// Cancellation is a normal outcome, not a failure.
if (isCancelledError(error)) return;
throw error;
}
};
return (
<View>
{user ? (
<>
<Text>Signed in as {user.email}</Text>
<Button
title="Sign out"
onPress={async () => {
await EssentialGoogleSignin.signOut();
setUser(null);
}}
/>
</>
) : (
<Button title="Sign in with Google" onPress={signIn} />
)}
</View>
);
}Native button
import EssentialGoogleSignin, { GoogleSigninButton } from "essential-google-signin";
<GoogleSigninButton
size={GoogleSigninButton.Size.Wide}
color={GoogleSigninButton.Color.Dark}
onPress={() => EssentialGoogleSignin.signIn()}
/>;| Prop | Values |
| --- | --- |
| size | Size.Standard (230×48), Size.Wide (312×48), Size.Icon (48×48) |
| color | Color.Light, Color.Dark |
| disabled | boolean |
| onPress | Called when the native button is tapped |
Native-only. It renders nothing useful on web.
API
configure(options?): Promise<ConfigureResult>
Reads the client IDs the plugin wrote into the native project and prepares the platform SDK. Call it once, and await it before signing in.
type ConfigureOptions = {
/** Android only. `false` always shows the account chooser. Default `true`. */
androidAutoSelectEnabled?: boolean;
};
type ConfigureResult = {
webClientId: string | null; // Android: from the manifest. iOS: the server client ID.
androidClientId: string | null; // Android only, informational
iosClientId: string | null; // iOS only
};Throws ERR_CONFIG when the client IDs are missing from the native config.
signIn(options?): Promise<GoogleUser>
Runs the interactive flow and resolves with the signed-in user.
type SignInOptions = {
/**
* Sent verbatim and checked against the token's `nonce` claim before resolving.
* Generate it on your server and compare it there too. Omitted: a random nonce
* is generated and checked locally.
*/
nonce?: string;
};Rejects with ERR_SIGN_IN_CANCELLED when the user backs out — check for that before treating a
rejection as a failure.
signInSilently(): Promise<GoogleUser>
Signs a returning user in without any UI and refreshes their ID token. On Android it asks Credential Manager only for accounts that already authorized this app; on iOS it restores the SDK's persisted session.
Rejects with ERR_NO_CREDENTIAL when nobody is signed in — the expected first-launch outcome, not
an error worth surfacing.
getCurrentUser(): Promise<GoogleUser | null>
On iOS this reads the SDK's persisted session and survives app restarts. Android's Credential
Manager keeps no session, so it reports the user who signed in during this process and returns
null after a restart — use signInSilently() there.
signOut(): Promise<void>
Clears the local session. Does not revoke the grant.
revokeAccess(): Promise<void>
Revokes the app's access entirely.
iOS only. Android's Credential Manager ID-token flow holds no on-device grant to revoke, so this
rejects with ERR_NOT_SUPPORTED there. Revoke from your backend instead, then call signOut().
hasPlayServices(): Promise<boolean>
Whether Google Play Services is available. Always true on iOS, always false on web.
Types
type GoogleUser = {
id: string; // the `sub` claim — Google's stable account ID
email: string;
emailVerified: boolean; // false when the claim is absent
name: string | null;
givenName: string | null;
familyName: string | null;
pictureUrl: string | null;
locale: string | null; // in practice, Android only
idToken: string; // audience is your web client ID on both platforms
};Fields Google omits are null, not "", so "absent" is distinguishable from "blank".
Error handling
Every rejection carries a code on error.code, identical across platforms.
import { GoogleSigninErrorCode, getGoogleSigninErrorCode } from "essential-google-signin";
switch (getGoogleSigninErrorCode(error)) {
case GoogleSigninErrorCode.Cancelled:
break; // user backed out
case GoogleSigninErrorCode.NoCredential:
break; // nobody signed in
default:
reportError(error);
}| Code | Meaning |
| --- | --- |
| ERR_SIGN_IN_CANCELLED | The user dismissed the sheet or dialog. Not a failure. |
| ERR_NO_CREDENTIAL | No eligible Google account. Expected from signInSilently(). |
| ERR_NOT_CONFIGURED | signIn() was called before configure() resolved. |
| ERR_CONFIG | Client IDs missing from the native config. Check the plugin and re-prebuild. |
| ERR_PLAY_SERVICES_UNAVAILABLE | Android: Play Services missing or out of date. |
| ERR_TOKEN | The ID token could not be decoded, or its nonce did not match. |
| ERR_NO_UI_CONTEXT | No activity (Android) or view controller (iOS) to present on. |
| ERR_NOT_SUPPORTED | No equivalent on this platform — revokeAccess() on Android, anything on web. |
| ERR_SIGN_IN_FAILED | Anything else during sign-in. |
| ERR_SIGN_OUT_FAILED | Anything else during sign-out. |
isCancelledError(error) is a shorthand for the first row.
Backend verification
signIn() gives you an ID token. Trust it only after your server verifies it. The audience is your
web client ID on both platforms, so a single check covers everything:
const { OAuth2Client } = require("google-auth-library");
const client = new OAuth2Client(WEB_CLIENT_ID);
async function verify(idToken) {
const ticket = await client.verifyIdToken({ idToken, audience: WEB_CLIENT_ID });
const payload = ticket.getPayload();
return { id: payload.sub, email: payload.email, name: payload.name };
}The module decodes the token locally to populate GoogleUser, without verifying its signature and
without a network call. That is deliberate: the credential arrives over IPC from Google Play
Services (Android) or from the SDK (iOS), and on-device verification only added a round-trip to
Google's certificate endpoint that made sign-in fail offline. Signature verification belongs on your
server, where the check is meaningful.
Platform notes
| | Android | iOS |
| --- | --- | --- |
| Implementation | Credential Manager | GoogleSignIn SDK |
| Session persists across restarts | No — use signInSilently() | Yes |
| getCurrentUser() after a restart | null | The stored user |
| revokeAccess() | ERR_NOT_SUPPORTED | Supported |
| locale claim | Usually present | Usually absent |
| androidAutoSelectEnabled | Applies | Ignored |
Web
Web is not supported — this wraps two native SDKs. The module still imports cleanly so your bundle
builds: hasPlayServices() resolves false, getCurrentUser() resolves null, and the sign-in
methods reject with ERR_NOT_SUPPORTED. Gate on Platform.OS, or integrate Google Identity
Services for the web separately.
Troubleshooting
ERR_CONFIG / "No web client ID in AndroidManifest.xml" — the plugin didn't run, or the native
project is stale. Run npx expo prebuild --clean. Note that 1.0.0 changed the manifest metadata
keys, so a project prebuilt with 0.x must be regenerated.
Android: ERR_PLAY_SERVICES_UNAVAILABLE, or "getCredentialAsync no provider dependencies found"
— Google Play Services on the device is older than the 23.08.15 that Credential Manager's Google
provider requires, so it declines to act as a provider. Despite the wording, the underlying message
is not about a missing Gradle dependency.
This bites most often on emulators: many system images ship a Play Services from the year the image
was cut, and it is never updated. Check with adb shell dumpsys package com.google.android.gms | grep versionName,
and use a recent Google Play system image or a real device. hasPlayServices() checks against this
same minimum, so it returns false on such a device rather than a misleading true.
Android: "Cannot find a matching credential" — the SHA-1 in the Google Cloud console doesn't
match the certificate the app was signed with. Get it from ./gradlew signingReport and add it to
your Android OAuth client. Remember that debug, release, and Play App Signing all use different
certificates.
Android: BAD_AUTHENTICATION / "Long live credential not available" — webClientId is not
actually a Web application client. The plugin now rejects the obvious cases at build time.
iOS: "Your app is missing support for the following URL schemes" — the reversed client ID isn't
in Info.plist. Run npx expo prebuild --clean.
iOS: "package 'apple' is using Swift tools version 6.2.0 but the installed version is 6.1.0" — Expo SDK 57 ships Swift packages requiring Swift tools 6.2, so it needs Xcode 26 or newer. Update Xcode, or on CI use a runner image that provides it.
iOS: "cannot inherit from class 'SharedObject' (compiled with Swift 5.10)" — a stale ios/Pods
left over from an earlier Expo SDK. Run npx expo prebuild --platform ios --clean.
iOS: "could not build Objective-C module 'GoogleUtilities'" — GoogleSignIn 8.0+ pulls in
AppCheckCore, whose Objective-C dependencies ship without module maps. The plugin patches the
Podfile for this automatically; if you manage your Podfile by hand, add after use_native_modules!:
pod 'GoogleUtilities', :modular_headers => true
pod 'RecaptchaInterop', :modular_headers => trueA backend rejects iOS tokens but accepts Android ones — that was the pre-1.0 behavior, where iOS
tokens were minted for the iOS client ID. Upgrade and re-prebuild so GIDServerClientID is written.
Migrating from 0.5.x
signIn() used to resolve { success: true, data: {...} }. It now resolves the user directly:
- const result = await EssentialGoogleSignin.signIn();
- setUser(result.data);
+ const user = await EssentialGoogleSignin.signIn();
+ setUser(user);Also changed:
signOut()resolvesundefinedinstead of{ success: true }.configure()resolves the actual client IDs instead of{ success: true }— it previously contradicted its own type.GoogleUserDatais nowGoogleUser; the old name remains as a deprecated alias.- Optional profile fields are
nullinstead of"". - Error codes changed:
SIGN_IN_ERRORbecameERR_SIGN_IN_FAILED, and cancellation now has its ownERR_SIGN_IN_CANCELLEDrather than being indistinguishable from a real failure. - iOS ID tokens now carry the web client ID as their audience. If your backend was verifying iOS tokens against the iOS client ID as a workaround, switch it to the web client ID.
- The Android manifest metadata moved out of the Play Games namespace, so
npx expo prebuild --cleanis required. androidClientIdis now optional in the plugin config.- New:
signInSilently(),getCurrentUser(),revokeAccess(),signIn({ nonce }),GoogleSigninErrorCode,getGoogleSigninErrorCode(),isCancelledError().
Development
npm install
npm run build
npm run lint
npm test
cd example
npm install
npx expo prebuild --clean
npx expo run:ios # or run:androidThe example app doubles as a test harness. Run checks exercises the whole API and asserts the
contract — that configure() returns the client IDs, that the user object's optional fields are
null rather than "", that the ID token's audience is the web client ID, and that
revokeAccess() rejects with ERR_NOT_SUPPORTED on Android. The ID token card decodes the
token in place so you can read aud, nonce and expiry without leaving the app, and the two
interactive checks cover the paths that need a human: dismissing the sheet should give
ERR_SIGN_IN_CANCELLED, and a supplied nonce should come back in the token.
License
MIT © Kevin Jossendal
