Halit Software← All posts
4 min

Supabase Google Sign-In Fails Only on iOS: "Passed nonce and nonce in id_token should either both exist or not"

I added Google and Apple sign-in to the gaste.co app this week. The app is React Native (Expo, bare workflow) and auth is Supabase, so the recipe is the one from the Supabase docs: get an ID token from the native SDK, hand it to signInWithIdToken, done.

Apple worked on the first try. Google worked on Android on the first try. Google on iOS opened the account picker, the sheet closed, and the app showed its generic "sign-in failed" alert. Nothing else.

If you landed here from a search, the short version: the iOS Google SDK started sending a nonce, Supabase demands to see that nonce too, and the React Native library gives you no way to get it. The fix is one toggle in the Supabase dashboard. The rest of this post is how I found that out.

The setup

  • @react-native-google-signin/google-signin 16.1.5, which pulls in GoogleSignIn iOS SDK 9.2.0 and AppAuth 2.1.0
  • @supabase/supabase-js 2.115
  • The "original" Google Sign-In API of the library, configured with a web client ID and an iOS client ID

The sign-in code is the usual four lines:

const response = await GoogleSignin.signIn();
const idToken = response.data?.idToken;
const { error } = await supabase.auth.signInWithIdToken({ provider: 'google', token: idToken });

No nonce anywhere, same as every example I could find.

Finding the actual error

The app only shows a generic message on failure, and a TestFlight build drops console.warn, so the phone told me nothing. The Supabase side did. Under Logs → Auth Logs in the dashboard, the failed request was right there:

{
  "path": "/token",
  "grant_type": "id_token",
  "status": 400,
  "error": "invalid request: Passed nonce and nonce in id_token should either both exist or not."
}

So Supabase received the token, decoded it, found a nonce claim inside, and refused because I had not passed a nonce parameter alongside it. That is the rule: both or neither.

I had not asked for a nonce. Where did it come from?

Where the nonce comes from

GoogleSignIn on iOS builds its authorization request with AppAuth. AppAuth's OIDAuthorizationRequest generates a random state and a random nonce for every request unless you hand it explicit values. GoogleSignIn 9 calls it with nonce: nil, so AppAuth makes one up, Google echoes it back inside the ID token, and the token you receive carries a nonce claim you never chose.

The React Native library then returns idToken and nothing else. Its signIn() has no nonce option in the original API, and the response has no nonce field. You hold a token with a nonce in it and no way to prove you know that nonce.

Android does not have this problem. The Android SDK the library uses (requestIdToken on the legacy Google Sign-In API) puts no nonce in the token, so "neither" is satisfied and Supabase accepts it. That asymmetry is what made this look like an iOS-only bug for a while.

The fix

Supabase → Authentication → Providers → Google → turn on Skip nonce checks → Save.

No code change, no rebuild. The TestFlight build that had been failing signed in on the next attempt.

What you give up: the nonce is replay protection for the ID token. With the check skipped, a token that leaks in transit could be replayed until it expires. The audience check still applies, so tokens minted for someone else's client are still rejected, and the tokens are short-lived and travel over TLS. For my app that trade is fine. Supabase describes the toggle for exactly this situation, "such as with iOS", so it is not an obscure workaround.

If you would rather keep the check

You need a library that lets you pass a nonce into the request and returns the token with that nonce, then give Supabase the raw value. Supabase compares its SHA-256 against the claim, so you pass the hash to Google and the raw string to Supabase. Apple's SDK works this way (expo-apple-authentication takes a nonce), which is why Apple sign-in kept its check. The original Google API in this library does not expose the parameter, at least as of 16.1.5. Check the library's signIn typings before assuming that has changed.

Decoding the JWT on the client to read the nonce out and pass it back is not a fix. Anyone holding the token can do the same, so it proves nothing. Skip the check honestly instead.

Takeaways

  • A generic client error and a 400 in the server log are two different amounts of information. Look at the Supabase Auth logs before touching code.
  • "Works on Android, fails on iOS" with identical JavaScript usually means the two native SDKs disagree about a token detail. The nonce is one of those details.
  • Remember the toggle exists. When a future SDK version lets you pass a nonce, turn the check back on.