Flutter + Supabase: A Production-Ready Auth Flow
Supabase has become one of the more popular backends for indie Flutter apps — Postgres, auth, storage, and realtime, without running your own infrastructure for the parts that don’t need to be self-hosted. Auth is usually the first thing you wire up, and it’s also where most tutorials stop short of what you actually need in production: session persistence, deep-link email confirmation, and proper error states.
This is the flow I use across projects — the version that survives an app being killed and reopened, not just the happy path.
Setup
dependencies:
supabase_flutter: ^2.0.0
await Supabase.initialize(
url: 'https://your-project.supabase.co',
anonKey: 'your-anon-key',
);
final supabase = Supabase.instance.client;
Two things worth doing immediately: never hardcode the anon key directly in a committed file — pull it from --dart-define or a .env loaded at build time — and initialize Supabase in main() before runApp(), not lazily on first use, or you’ll race against widgets that assume a client already exists.
Sign up and sign in
Future<void> signUp(String email, String password) async {
try {
final response = await supabase.auth.signUp(
email: email,
password: password,
);
if (response.user == null) {
throw Exception('Sign up failed');
}
} on AuthException catch (e) {
// e.message is user-facing safe in Supabase's AuthException
throw Exception(e.message);
}
}
Future<void> signIn(String email, String password) async {
try {
await supabase.auth.signInWithPassword(email: email, password: password);
} on AuthException catch (e) {
throw Exception(e.message);
}
}
Catching AuthException specifically (rather than a generic catch (e)) matters because Supabase’s auth errors already come with reasonable user-facing messages — “Invalid login credentials,” “Email not confirmed” — and re-wrapping them loses that clarity.
The part most tutorials skip: session persistence
supabase_flutter persists sessions to local storage by default, but the mistake I see most often is checking auth state once, at app startup, instead of listening for changes. That breaks the moment a session expires or refreshes mid-use.
class AuthGate extends StatelessWidget {
const AuthGate({super.key});
@override
Widget build(BuildContext context) {
return StreamBuilder<AuthState>(
stream: supabase.auth.onAuthStateChange,
builder: (context, snapshot) {
final session = snapshot.data?.session;
if (session != null) {
return const HomeScreen();
}
return const LoginScreen();
},
);
}
}
onAuthStateChange fires on sign-in, sign-out, token refresh, and password recovery — routing your entire app root through this stream means every one of those events is handled consistently in one place, instead of scattered checks in individual screens.
Deep links for email confirmation
This is the step that trips up most Flutter + Supabase setups: by default, clicking the confirmation link in a signup email opens a browser, not your app. Fixing this requires configuring a custom URL scheme and handling it on both platforms.
Supabase dashboard: set your redirect URL under Authentication → URL Configuration to something like com.yourapp://login-callback.
Android (AndroidManifest.xml):
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="com.yourapp" android:host="login-callback" />
</intent-filter>
iOS (Info.plist):
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array><string>com.yourapp</string></array>
</dict>
</array>
Then pass the redirect explicitly when signing up:
await supabase.auth.signUp(
email: email,
password: password,
emailRedirectTo: 'com.yourapp://login-callback',
);
Without this, users confirming their email get dropped into a browser tab with no path back into your app — a surprisingly common source of silent signup drop-off that’s easy to miss because the signup itself technically “succeeded.”
Sign out, properly
Future<void> signOut() async {
await supabase.auth.signOut();
}
The AuthGate above handles the redirect automatically via onAuthStateChange — you don’t need to manually navigate after calling this, and doing so tends to create race conditions with the stream-driven rebuild.
Error states worth handling explicitly
- Email not confirmed: Supabase returns a specific error here — surface it distinctly from “wrong password” so users know what to fix.
- Network failure during auth: wrap auth calls with a timeout and a retry affordance; a hung auth screen with no feedback is one of the more common sources of App Store review friction.
- Expired/invalid deep link tokens: if a user taps an old confirmation link, handle it as its own state rather than a generic error.
Where this fits with the rest of a Flutter/Supabase stack
Auth is usually step one of a larger flow — most apps that need this also need row-level security policies matching the authenticated user, and often some form of identity verification beyond email/password (that’s a big enough topic to get its own article). But getting this foundational flow right — stream-driven state, working deep links, honest error messages — is what separates an app that feels solid from one that mysteriously logs people out or strands them after signup.

Pingback: Building A KYC/Liveness Detection Flow In Flutter Without Lock-in -