Add Google sign-in to an Expo app and an Express API, step by step
A complete walkthrough with screenshots: Google Cloud consent screen, the Web and Android OAuth clients, the SHA-1 from EAS, ID token verification on an Express API, account linking, and the two mistakes that cost me an afternoon.
On this page · 16 sections
This is the exact path I followed to add Google sign-in to LogeAfrica, an Expo (React Native) app talking to an Express API. It already had email and password login, and the login sheet had a "Se connecter avec Google" button that only said "coming soon". Every step below has the screenshot I took while doing it, and the places where I got stuck are marked.
What you are building
The app opens the native Android account picker and gets a Google ID token. It sends that token to your API. The API checks it with Google, finds or creates the user, and answers with the same session token your normal login already returns. Nothing after login has to change.
You will need:
- 01an Expo app built with EAS (the sign-in library is a native module, so it does not run in Expo Go),
- 02an Express API with a users table,
- 03a Google account to create the Google Cloud project.
Create a new project in the Google Cloud console and open Google Auth Platform. A new project has no consent screen yet, so you start there.
Step 1 (Google Cloud): The consent screen
The wizard has four short pages. First, the app name and a support email:
Then the audience. Choose External unless your users all belong to one Google Workspace organization:
Then a contact email, where Google will write to you about project changes:
And finally agree to the policy and create the configuration:
You land on the OAuth overview, which already tells you what is missing: no OAuth clients yet.
Step 2 (Google Cloud): Add yourself as a test user
While the app is in Testing mode, Google rejects every account that is not on the list. Open Audience, scroll to Test users and add every Gmail address you will test with. I added three, one of them a second account to test switching.
Publishing later, for the basic email and profile scopes only, is a button click and does not require a Google review.
Step 3 (Google Cloud): The Web client
Open Clients. Empty, as expected:
Click Create client and choose Web application. Give it a name. This looks wrong for a mobile app, but it is the client whose ID the app and the API will share.
Create it and copy the Client ID. The dialog also shows a client secret exactly once.
You do not need the secret because the API only verifies ID tokens. It never exchanges authorization codes, which is the only thing the secret is for.
Step 4 (Google Cloud): Find your SHA-1 with EAS
The Android client (next step) needs the SHA-1 fingerprint of the key that signs your APK. With EAS builds that key lives on Expo's servers, and you read it with eas credentials. On my machine this failed first:
1eas : The term 'eas' is not recognized as the name of a cmdlet, function, script file, or operable program.eas-cli was not installed globally. Run it through npx instead:
1npx eas-cli credentialsChoose Android, then the build profile you actually ship (mine is preview), and the keystore details are printed:
Two things to know here. The development, preview and production profiles can each have their own keystore, so a different profile is a different SHA-1. And when you publish on the Play Store, Play re-signs your app with its own key, which is yet another SHA-1 and another Android client later.
Step 5 (Google Cloud): The Android client
Back in Google Cloud, create a second client, this time of type Android. Fill in a name, your package name (the application identifier above, which is android.package in app.json) and the SHA-1 you just copied:
Google confirms it with another client ID:
That second ID confuses everyone. Your code only ever needs the Web client ID. The Android client exists so Google can check that the request comes from your signed app, and it does that by matching package name and SHA-1, not by an ID you pass around.
Step 6 (API): Verify the token
I wanted no new dependency. Google's tokeninfo endpoint validates the signature and expiry for you, so the service asks it, then checks the claims that are yours to check: the audience must be one of your clients, the issuer must be Google, and there must be an email.
1const response = await axios.get("https://oauth2.googleapis.com/tokeninfo", {2 params: { id_token: idToken },3 timeout: 8000,4});5// ...6if (!audiences.includes(claims.aud) || !GOOGLE_ISSUERS.includes(claims.iss) || !claims.sub || !claims.email) {7 throw new UnauthorizedError("Jeton Google invalide.");8}A 400 from Google means a bad or expired token and becomes a 401. A timeout or a 5xx is your side of the fence and becomes a 502. The accepted audiences come from an environment variable, and if it is empty the endpoint answers 503 instead of accepting anything:
1GOOGLE_OAUTH_CLIENT_IDS=<your Web client ID>One extra HTTPS call per sign-in is fine at small scale. At large scale, verify the JWT locally against Google's published keys instead.
Step 7 (API): Add a column and the endpoint
Store the Google account id (the sub claim) on the user. A unique partial index keeps it one to one:
1await queryRunner.query(`ALTER TABLE "users" ADD COLUMN "google_sub" varchar(64)`);2await queryRunner.query(`3 CREATE UNIQUE INDEX "UQ_users_google_sub" ON "users" ("google_sub") WHERE "google_sub" IS NOT NULL4`);Then POST /auth/google takes { idToken }, verifies it, resolves a user and issues a session. Rate-limit it per IP like your other auth routes.
Step 8 (API): Decide how accounts are linked
After verification you have a Google id, an email and a flag saying whether Google verified that email. The order I use:
- 01A user already linked to that Google id.
- 02Otherwise a user with the same email: link the Google id to it.
- 03Otherwise create a new account.
Emails are unique anyway, so rule 2 is not optional: someone who registered with a password and later taps "Sign in with Google" has to land in the same account. I tested exactly this and it opened the same account with the same data.
A Google-created user still needs a password hash if your column is not nullable. I store the hash of a random token. Nobody knows it, and "forgot password" still lets the person set a real one.
The security hole in that rule. If your registration does not require a verified email, an attacker can register victim@gmail.com with a password they chose. When the real owner later signs in with Google, the accounts link, and the attacker still knows the password. Linking by email is only safe when the email is verified on both sides. The fix: when Google links to an account whose email was never verified, throw away its password and end its sessions.
1if (user) {2 user.googleSub = identity.sub;3 if (!user.isEmailVerified) {4 user.passwordHash = await bcrypt.hash(createOpaqueToken(), 10);5 await this.sessions.delete({ userId: user.id });6 }7}The cost lands on one narrow group: someone who registered with a password, never verified, and then came back through Google. They recover with a password reset in a minute. I decided that was better than refusing to link, which would lock the real owner out of Google sign-in for good.
Step 9 (Expo app): Install and configure
1npx expo install @react-native-google-signin/google-signinThe install adds the config plugin to app.json. Put the Web client ID in an environment variable the app can read, for EAS builds in eas.json:
1"env": {2 "EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID": "<your Web client ID>"3}A Web client ID is a public identifier, so committing it is fine. Do not commit the secret, which you never needed.
Step 10 (Expo app): Call the library without ever crashing
This is a native module. In Expo Go it does not exist, and merely importing it throws, which would take the whole login sheet down. So I load it lazily inside a function and return a typed result for every outcome:
1export type GoogleSignInResult =2 | { status: "success"; idToken: string }3 | { status: "cancelled" }4 | { status: "unavailable"; reason: "not_configured" | "no_native_module" | "play_services" }5 | { status: "error" };The call itself configures the library with the Web client ID, checks Play Services, and opens the picker:
1GoogleSignin.configure({ webClientId: GOOGLE_WEB_CLIENT_ID });2await GoogleSignin.hasPlayServices({ showPlayServicesUpdateDialog: true });3await GoogleSignin.signOut().catch(() => {});4const response = await GoogleSignin.signIn();The signOut() before signIn() matters: without it the library can silently reuse the last account, and users can never switch to another one. Cancelling the picker is not an error and should show nothing.
Step 11 (Expo app): Send the token to your API
1google: (idToken: string) =>2 apiFetch<{ user: VisitorUser; sessionToken: string; isNewUser: boolean }>("/auth/google", {3 method: "POST",4 body: JSON.stringify({ idToken }),5 }),On success, store the session token exactly as the email login does. The rest of the app does not know how the user signed in.
Deploy and test
The order that saved me time: consent screen and test users, the Web client, the Android client, then deploy the API with the audience variable set, and only then build the app and test on a real device. Build with EAS, install the APK, tap the button.
The error message that lied
My first test on the phone. The account picker opened, I chose my account, and then:
Getting as far as the picker proved a lot: the Android client, the package name and the SHA-1 were all right, because Google had handed the app a token. The failure was on the call to my API. So I stopped looking at the app and called the endpoint directly:
1POST /api/v1/auth/google with a too-short token -> 400 (the route exists, validation ran)2POST /api/v1/auth/google with a fake long token -> 503 (the server refuses to even try)A 503 meant the API was deployed but GOOGLE_OAUTH_CLIENT_IDS was not set on the host. The server was doing the right thing. My client code was the problem: its catch block turned every non-401 answer into "network error", so a server that answered "no" was reported as the phone being offline. I changed the handler to tell the cases apart: 401 means the Google account could not be verified, 502/503/404 means the service is unavailable right now, and only a request that never got an answer is shown as a network error. The real fix was one environment variable on the host and no rebuild.
The lesson: probe the endpoint with a plain HTTP call before debugging the app, and never let a generic catch-all message claim a cause you have not established.
Test checklist
- 01A brand-new Google account creates a user.
- 02An existing email and password account with the same email links and opens the same account.
- 03Switching to a second Google account works (this is what
signOut()is for). - 04Cancelling the picker shows nothing.
- 05With no network, you see the network error and only then.
- 06In Expo Go, the button shows a "not available" message instead of crashing.
Still to do on my side
- 01The Play Store signing key will need its own Android client, with Play's SHA-1, before the app is published.
- 02Sign-in is Android only for now, since there is no iOS build.
- 03The same Google Cloud project will also hold the Maps API keys. That deserves its own post.