DEV Community

Boxcar Kits
Boxcar Kits

Posted on

Native Google sign-in with Supabase in a Capacitor Android app (and the bug that cost me a build)

I wanted the normal Android Google sign-in for a Capacitor app: the system account picker, no browser tab, ending in a Supabase session. The flow is simple once it works. The picker gives you a Google ID token, and Supabase checks that token with signInWithIdToken.

Getting it working took longer than I expected. Most of the time went into Google Cloud setup, plus one bug in my own code that only showed up on a phone. Here's the setup that works, then the bug.

Stack: Capacitor 8, React, TypeScript, @supabase/supabase-js, and @capgo/capacitor-social-login 8.5.x.

1. Google Cloud: one Web client, and Android clients for each signing key

Put everything in one Google Cloud project.

OAuth consent screen. Choose External. While the app is in Testing mode, only accounts listed under Audience > Test users can sign in. If you test with a Google account that isn't on the list, sign-in fails. Add every account you plan to test with.

Web application client. This is the client the app actually uses, even on Android. Its client ID goes into your app config, and its secret goes only into Supabase. Add Supabase's callback URL (https://<project-ref>.supabase.co/auth/v1/callback) as a redirect URI if you also want the browser OAuth fallback for local dev.

Android clients. The app never sends these IDs anywhere. Google just checks that the installed app's package name and signing certificate are registered in the same project as the Web client. The catch is that you need one Android client per certificate that signs an install:

  • your debug keystore, for local builds (cd android && ./gradlew signingReport)
  • your upload key, for release builds you sideload (keytool -printcert -jarfile app-release.aab)
  • the Play app signing key, for anything installed from Google Play, including Internal testing. Google re-signs your bundle, so the upload key's SHA-1 doesn't cover these installs. You'll find this one in Play Console under App integrity.

Use the same package name for all of them. SHA-1 fingerprints are public, so it's fine to paste them anywhere.

2. Supabase: enable the Google provider

In Authentication > Sign In / Providers > Google:

  • Enable it, and enter the Web client ID and secret.
  • Make sure the Web client ID is in the authorized client IDs list. The ID token's audience is the Web client, and Supabase rejects tokens whose audience it doesn't know.
  • Leave Skip nonce check off. We'll send a nonce properly.

3. Initialize the plugin with the Web client ID

The plugin's online mode returns an ID token, which is what Supabase needs. Load the plugin lazily so that web builds don't pull it in:

import type { SocialLoginPlugin } from '@capgo/capacitor-social-login';

let pluginPromise: Promise<SocialLoginPlugin> | null = null;

function getPlugin(webClientId: string) {
  pluginPromise ??= import('@capgo/capacitor-social-login').then(async ({ SocialLogin }) => {
    // The WEB client ID, not the Android one.
    await SocialLogin.initialize({ google: { webClientId, mode: 'online' } });
    return SocialLogin;
  });
  return pluginPromise;
}
Enter fullscreen mode Exit fullscreen mode

Passing the Android client ID here is a classic mistake. On Android, webClientId becomes the server client ID that Credential Manager requests a token for.

4. The nonce: hash for Google, raw for Supabase

Supabase checks the nonce to stop replayed ID tokens. The rule that tripped me up: Google gets the SHA-256 hex digest of the nonce, and Supabase gets the raw value. Supabase hashes the raw value itself and compares it with the nonce claim in the token.

const toHex = (bytes: Uint8Array) =>
  Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');

export async function createNoncePair() {
  const random = crypto.getRandomValues(new Uint8Array(32));
  const rawNonce = toHex(random);
  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(rawNonce));
  return { rawNonce, nonceDigest: toHex(new Uint8Array(digest)) };
}
Enter fullscreen mode Exit fullscreen mode

If you send the same value to both, or send a nonce on only one side, Supabase rejects the sign-in with a nonce error.

5. Sign in

const { rawNonce, nonceDigest } = await createNoncePair();
const plugin = await getPlugin(WEB_CLIENT_ID);

const res = await plugin.login({
  provider: 'google',
  options: { nonce: nonceDigest }, // no scopes, see below
});
if (res.result.responseType !== 'online' || !res.result.idToken) {
  throw new Error('No ID token');
}

const { error } = await supabase.auth.signInWithIdToken({
  provider: 'google',
  token: res.result.idToken,
  nonce: rawNonce,
});
if (error) throw error;
Enter fullscreen mode Exit fullscreen mode

Some errors deserve their own handling, so the user doesn't just see "something went wrong":

  • Cancellation. The user closed the picker. Treat it as a normal outcome and show no error.
  • [28444] Developer console is not set up correctly. This almost always means the package name or SHA-1 of the installed build isn't registered, or the clients are in different Google Cloud projects. Show a message pointing at the setup.
  • No credentials available. The phone has no Google account on it.

The bug that cost me a build

My first version of the login call looked like this:

await plugin.login({
  provider: 'google',
  options: { scopes: ['email', 'profile'], nonce: nonceDigest },
});
Enter fullscreen mode Exit fullscreen mode

It looks harmless. You want the email and profile, so you ask for them. On a real phone, the app installed from Play Internal testing showed my generic "Something went wrong" message as soon as I tapped the button.

My first guess was SHA-1, since that's what usually breaks. But the Supabase auth logs showed no token request from the phone at all. So the failure happened before Supabase ever got involved.

The answer was in the plugin's Android source. If you pass a scopes array, the plugin checks whether your MainActivity implements ModifiedMainActivityForSocialLoginPlugin. If it doesn't, it rejects right away with:

You CANNOT use scopes without modifying the main activity. Please follow the docs!
Enter fullscreen mode Exit fullscreen mode

That interface is meant for requesting extra Google API scopes through a separate authorization step. A plain sign-in doesn't need it. The plugin already adds openid, userinfo.email and userinfo.profile by default, and the ID token carries the email and name either way.

My error mapping only knew about cancellation, the 28444 setup error and the no-account case, so this message landed in the "unknown" bucket. Worse, my fallback logged only error.name, which threw away the one sentence that would have explained everything.

The fix was to drop scopes. I pulled the options into a tiny function so a test could guard it:

export function googleLoginOptions(nonceDigest: string) {
  return { nonce: nonceDigest };
}
Enter fullscreen mode Exit fullscreen mode
it('sends no scopes (they make the Android plugin reject)', () => {
  expect(googleLoginOptions('abc')).not.toHaveProperty('scopes');
});
Enter fullscreen mode Exit fullscreen mode

I also changed the unknown-error path to log the plugin's message, with emails and anything that looks like a token redacted and the length capped. The next surprise should take a minute to diagnose, not a rebuild and a re-upload.

Checklist

  • Consent screen: every testing account is listed as a test user.
  • One Web client. Its ID goes in the app and in Supabase, and its secret goes only in Supabase.
  • Android clients with the same package name for debug, upload key and Play app signing key.
  • webClientId is the Web client ID.
  • The SHA-256 hex nonce goes to Google, and the raw nonce goes to signInWithIdToken.
  • No scopes option unless you have actually changed MainActivity.
  • When a sign-in fails on a device, check the Supabase auth logs first. If no request ever reached Supabase, the problem is on the device or in your Google config, not in Supabase.
  • Log the plugin's error message, safely redacted, not just its name.

I packaged this setup (plus RevenueCat, AdMob, and Play release scripts) into a paid starter kit, First Track: https://boxcarkits.gumroad.com/l/first-track

Top comments (0)