Engineering

Supabase Auth for Flutter: Deep Links, Google Sign-In, and Staying In-App

In-app browser OAuth is not a reliable iOS Google MVP. Prefer native Google Sign-In with a matching nonce, allow-list deep links for email flows, and register Play App Signing SHA-1 before launch.

Shipping Supabase Auth into a Flutter companion app that already shares accounts with a Next.js website looks straightforward: same project URL, same publishable key, call signInWithOAuth. In practice, teams often land users in Safari on the marketing site with no session in the app. The failure is usually configuration and launch mode — not a broken Supabase project.

This post is the playbook we wish we had before wiring mobile auth against an existing web stack: what the half-finished scaffolding looks like, why the external-browser path breaks, why in-app browser OAuth is not a reliable Google MVP on iOS, and the correct order of operations for deep links, email/password, and native Google with a matching nonce.

Core insight

Website cookie sessions (@supabase/ssr) and Flutter local sessions are different storage for the same Supabase project. Share URL + publishable key + user_metadata shape — but never assume a browser cookie magically appears in the app. For Google on iOS, treat native Sign-In as required — not a polish step after an in-app browser MVP. And never rely on LaunchMode.platformDefault in a customer-facing mobile flow.

Why Flutter + Supabase Auth Feels Half-Done

Flutter templates and early experiments often leave behind native deep-link scaffolding without any Dart auth code:

  • An Android intent-filter for something like myapp://login-callback
  • An iOS CFBundleURLSchemes entry for the same scheme
  • FlutterDeepLinkingEnabled = false (correct later, confusing when nothing consumes the link)
  • Sometimes a ProGuard keep rule for com.supabase.** while supabase_flutter is not even a dependency

That looks like auth was "removed" or "almost ready." Check git history before assuming there is a working Sign In screen to restore. Orphaned native filters without supabase_flutter, auth screens, and an initialized client are not a half-finished product — they are a trap that makes the OAuth failure mode feel familiar before you have written a line of Dart.

Common failure

signInWithOAuth with default LaunchMode.platformDefault opens the external Safari / Chrome app. If myapp://login-callback is not allow-listed under Supabase Redirect URLs, Supabase falls back to the project's Site URL (your website). The user finishes Google on the web and never returns to Flutter. The session never attaches in the app.

Inventory the Website Before You Touch Flutter

If a Next.js app already owns auth, inventory it first. Copy the Supabase URL and publishable key from the website env. Then call the live settings endpoint — do not trust the marketing UI alone:

curl -s "$SUPABASE_URL/auth/v1/settings" \
  -H "apikey: $SUPABASE_PUBLISHABLE_KEY"

Pay attention to:

  • Which providers are actually enabled (google, email, phone)
  • mailer_autoconfirm — when false, signUp often returns no session
  • disable_signup

A website can show a phone OTP tab while "phone": false on the project. Building SMS into the app before Twilio/phone is enabled wastes time. Also match user_metadata keys the website already writes — typically full_name and/or display_name — so profile labels stay consistent across surfaces.

Same project, different session storage

  • Next.js — often cookie sessions via @supabase/ssr, with OAuth returning through /auth/callback and exchangeCodeForSession.
  • Flutter — local session storage inside supabase_flutter. Authenticated website API routes that only read cookies need a Bearer token (or a BFF) if the app must call them as a logged-in user.
  • Sharedauth.users, provider config, and the same publishable key. An account created on the website should sign into the app and vice versa once both clients point at the same project.

Pick the Deep Link Early — Then Allow-List It

Decide the custom scheme before you test OAuth: {scheme}://login-callback. Register it in both platforms, turn off Flutter engine deep linking so app_links / Supabase can own the callback, and add that exact URL to the dashboard before the first Google tap.

// iOS Info.plist — CFBundleURLSchemes
myapp

// AndroidManifest.xml — VIEW / BROWSABLE intent-filter
scheme: myapp
host: login-callback

// Also set FlutterDeepLinkingEnabled = false
// so Flutter routing does not steal auth callbacks.

In Supabase: Authentication → URL Configuration → Redirect URLs. Add:

myapp://login-callback

Keep the existing website entries (https://yoursite.com/**, localhost, preview hosts). Removing them breaks the Next.js app. Skipping the mobile entry is what sends OAuth completers to the Site URL.

The Redirect URL allow-list still matters even when Google uses native Sign-In: email confirmation and password-reset links that use emailRedirectTo / redirectTo need the same deep link. Any OAuth provider that does return via a custom scheme still depends on this entry.

Initialize Supabase Correctly in Flutter

Add supabase_flutter (and google_sign_in + crypto if you need Google). Put secrets in gitignored dart-defines — never commit publishable keys into source if your team treats them as environment-bound. Initialize before runApp, use PKCE for mobile OAuth, and pass publishableKey when the project uses sb_publishable_… keys (the older anonKey parameter is the wrong fit for that key shape).

await Supabase.initialize(
  url: supabaseUrl,
  publishableKey: supabasePublishableKey,
  authOptions: const FlutterAuthClientOptions(
    authFlowType: AuthFlowType.pkce,
  ),
);

runApp(const MyApp());

Wrap a thin AuthService around the client: expose a notifier or stream of the current user from onAuthStateChange, map Supabase errors into product language, and keep guest commerce paths working if the product allows browsing without a session.

Email and Password: Ship This First

Email/password needs no Google Cloud setup. It is the fastest proof that the Flutter client talks to the same project as the website:

  • signInWithPassword for existing website accounts
  • signUp with the same metadata keys as the web app
  • resetPasswordForEmail / resend with redirectTo aimed at the allow-listed deep link

When mailer_autoconfirm is false, treat "no session after signUp" as success: show "check your email," switch the UI to Sign In mode, and do not pretend the user is signed in. That single UX choice prevents a class of confused support tickets.

Google: Native Is Required on iOS

There are two Google paths on paper. In production, treat native Google Sign-In as the required path on iOS. In-app browser OAuth is not a reliable MVP for Google — keep the deep-link allow-list for email and other flows, but do not plan a Google launch around LaunchMode.inAppBrowserView.

Why in-app browser OAuth fails as an iOS Google MVP

On iOS, LaunchMode.inAppBrowserView via supabase_flutter url_launcher opens SFSafariViewController, not ASWebAuthenticationSession. That distinction matters: custom-scheme redirects like myapp://login-callback often fail to bounce back into Flutter. Users finish Google, land on the website Site URL inside a sheet with a "Done" button, and are signed in on the web with no app session.

Do not treat inAppBrowserView as ASWebAuthenticationSession on iOS. Docs and mental models that equate the two will send you into a dead end.

Android is worse for browser OAuth: supabase_flutter forces LaunchMode.externalApplication for Google inside _launchAuthUrl, so the flow jumps into the system browser. That path is especially prone to "signed in on the website, back in the app still signed out." Avoid depending on signInWithOAuth for Google on Android.

// Still useful for non-Google OAuth that truly returns via deep link —
// not a reliable Google MVP on iOS, and forced external on Android for Google.
await supabase.auth.signInWithOAuth(
  OAuthProvider.google,
  redirectTo: 'myapp://login-callback',
  authScreenLaunchMode: LaunchMode.inAppBrowserView,
);

If you ever use browser OAuth for another provider, listen for auth state changes instead of assuming an immediate user. Clear a stuck "Connecting…" state if the user dismisses the sheet (WidgetsBindingObserver helps).

Native Google Sign-In (required for Google on iOS)

Flow: system account picker → ID token → supabase.auth.signInWithIdToken. No browser, no deep-link round trip on the happy path.

With google_sign_in 7.x, Google's SDK stamps a nonce into the ID token. Calling signInWithIdToken without a matching raw nonce yields:

Passed nonce and nonce in id_token should either both exist or not.

Generate a raw nonce, send the SHA-256 hex digest to Google via initialize(nonce: …), then pass the raw nonce to Supabase. Use the crypto package for sha256. Regenerate (and re-initialize) per attempt.

import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:google_sign_in/google_sign_in.dart';

final rawNonce = supabase.auth.generateRawNonce();
final hashedNonce = sha256.convert(utf8.encode(rawNonce)).toString();

final googleSignIn = GoogleSignIn.instance;
await googleSignIn.initialize(
  serverClientId: webClientId, // Web client used by Supabase Google provider
  clientId: iosClientId,       // iOS only
  nonce: hashedNonce,          // SHA-256 hex to Google
);

final account = await googleSignIn.authenticate();
final idToken = account.authentication.idToken;
// Authorize scopes / obtain accessToken as your google_sign_in version requires

await supabase.auth.signInWithIdToken(
  provider: OAuthProvider.google,
  idToken: idToken!,
  accessToken: accessToken,
  nonce: rawNonce,             // raw to Supabase
);

Dashboard and native wiring that teams miss:

  • Google Cloud: create an iOS OAuth client (bundle id) and an Android client (package + SHA-1 for the keystore that actually signs the install — debug vs Play App Signing).
  • Reuse the existing Web client ID as serverClientId.
  • Supabase → Providers → Google → Authorized Client IDs: this must contain the client ID that matches the ID token's aud claim. When you pass the Web client as serverClientId, Google sets aud to that Web client on both platforms — so the Web client ID alone is usually enough. Only add native client IDs here if you do not set serverClientId, in which case aud becomes the native client.
  • iOS Info.plist: add the reversed iOS client ID as a URL scheme.
  • Before Play Store launch: Android debug SHA-1 ≠ Play App Signing SHA-1. Register the Play App Signing certificate SHA-1 on the Android OAuth client (or create a second Android client). Otherwise Google works in debug and breaks in production.
  • Sign out of GoogleSignIn when the app signs out, or the next native attempt may silently reuse the last account.

Launch mode rule

Never rely on LaunchMode.platformDefault for customer OAuth. For Google, prefer native Sign-In with a matching nonce on both platforms. Do not treat inAppBrowserView as a safe iOS MVP, and do not depend on browser OAuth for Google on Android where supabase_flutter forces externalApplication.

A Minimal Auth Service Shape

Keep platform details behind one service so screens stay boring: Sign In / Register toggle, Google button, forgot password, account profile. Prefer native Google whenever client IDs are present — treat browser OAuth as a last resort for non-Google providers, not as the Google MVP.

Future<void> signInWithGoogle() async {
  if (!canUseNativeGoogle) {
    throw AuthFailure(
      'Native Google Sign-In is required. Browser OAuth is unreliable for Google on mobile.',
    );
  }
  await _signInWithNativeGoogle();
  // Session arrives from signInWithIdToken — no deep-link wait on the happy path
}

Checklist

Use this when a web app already runs Supabase Auth and a Flutter app must share users:

  1. Confirm the website uses Supabase; copy URL + publishable key.
  2. Call GET {SUPABASE_URL}/auth/v1/settings — note providers and mailer_autoconfirm.
  3. Match user_metadata keys (full_name / display_name).
  4. Pick {scheme}://login-callback; register in Info.plist + AndroidManifest; set FlutterDeepLinkingEnabled=false.
  5. Allow-list that exact URL in Supabase Redirect URLs (email confirm, password reset, and any OAuth that still uses deep links).
  6. Add supabase_flutter; initialize with PKCE and publishableKey before runApp.
  7. Ship email/password first (no Google Cloud required).
  8. If confirmation is required, UX must say "check your email" when sign-up returns no session.
  9. Google: prefer native Sign-In on iOS — do not rely on LaunchMode.inAppBrowserView as the MVP. On Android, avoid depending on the signInWithOAuth browser path (forced externalApplication).
  10. Native Google: Web + iOS client IDs, reversed iOS URL scheme, the aud-matching client ID in Supabase Authorized Client IDs (the Web client when you set serverClientId), SHA-256 hashed nonce to Google + raw nonce to signInWithIdToken, Play App Signing SHA-1 registered before launch.
  11. Verify: website account signs into the app and vice versa; Google stays in-app via native Sign-In; bad password shows a friendly error.

Gotchas worth tattooing on the runbook

  • Native intent-filters without Dart handlers look "half done" — they are not a working auth stack.
  • Missing Redirect URL → Site URL fallback → user stranded on the website.
  • Prefer native Google on iOS; do not rely on inAppBrowserView as an MVP. On iOS it is SFSafariViewController, not ASWebAuthenticationSession — custom-scheme callbacks often never return to Flutter.
  • Android Google browser OAuth: supabase_flutter forces externalApplication — expect "signed in on website, app still signed out."
  • google_sign_in 7.x + Supabase: hashed nonce to Google, raw nonce to signInWithIdToken; regenerate per attempt.
  • Phone UI on the website ≠ phone enabled; always check /auth/v1/settings.
  • serverClientId decides the ID token aud. Pass the Web client and aud is the Web client on iOS and Android alike — so "add every native client ID to Authorized Client IDs" is usually a red herring. Decode the token and check aud before chasing config.
  • Android Google needs the SHA-1 for that install. Register Play App Signing SHA-1 before shipping — debug SHA-1 alone will break production.
  • sb_publishable_… keys pair with publishableKey: in current supabase_flutter.
  • Rule out the emulator before you debug auth. Our "Android signs in but the app still shows Sign In" bug was a 2 GB AVD swapping ~1 GB, not OAuth — the session was created and persisted correctly, but the UI could not repaint and kept throwing ANRs. adb shell top -H -p <pid> tells them apart: high CPU is a spin or deadlock, idle CPU plus swap pressure is a starved device.

Do the allow-list and native Google choices first. Email/password will confirm the shared project; Google will feel polished once client IDs, nonce pairing, and Play SHA-1 land. Everything else — screens, error copy, guest paths — is ordinary product work on top of a session that actually attaches in the app.