Supabase Auth in Flutter: The Complete Setup Guide
Published: 2026-10-12 · 9 min read
The complete Supabase Flutter auth setup: email/password login, Google OAuth, magic links, auth-state routing, and the RLS policies that protect your data.
If you are wiring up authentication in a new Flutter project, this supabase flutter auth guide will take you from an empty project to a working email login, Google OAuth, and session management — all in one sitting. Most tutorials stop at sign-in and leave you to figure out auth state, row-level security, and token refresh on your own. Not this one.
Supabase is an open-source Firebase alternative built on Postgres, and its Flutter SDK (supabase_flutter) is mature enough in 2026 to be a serious default for indie apps and client projects alike. You get a Postgres database, authentication, storage, and realtime in one dashboard, with a generous free tier. Here is how to set it up properly.
Why Supabase Auth for Your Flutter App
Before touching code, it helps to know what you are buying into. Firebase Auth is battle-tested, but Supabase has three practical advantages for Flutter developers:
- Postgres under the hood. Your users table is just a table. You can query it with SQL, join it with app data, and enforce access with row-level security policies — no vendor-specific query language.
- Self-hostable. If a client or regulator demands data residency, you can move the whole stack to your own server later.
- Simple pricing mental model. Auth seats and database usage scale predictably, which matters when you estimate costs for a client proposal.
The trade-off: the Flutter community around Supabase is smaller than Firebase's, so you will occasionally debug from source code rather than Stack Overflow answers. In practice, the SDK API surface for auth is small and stable, so this rarely bites.
Step 1: Create a Supabase Project
- Go to supabase.com and create a free project. Note the Project URL and the anon public key from Settings → API.
- The anon key is safe to ship in your app — it is a public key. What actually protects your data is row-level security (RLS), which we will configure later. Never ship the
service_rolekey in a client app; it bypasses all RLS policies.
Step 2: Install and Initialize supabase_flutter
Add the dependency to your pubspec.yaml (use the latest stable — flutter pub add supabase_flutter resolves it for you):
dependencies:
supabase_flutter: ^x.y.z
Initialize once in main(), before runApp:
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Supabase.initialize(
url: 'https://xyzcompany.supabase.co',
anonKey: 'your-anon-key',
);
runApp(const MyApp());
}
In a real project, do not hardcode these values. Put them in --dart-define flags or a dotenv file that never gets committed. After initialization, the client is available anywhere as Supabase.instance.client.
Step 3: Email/Password Sign Up and Sign In
The core API is delightfully small. Here is a complete auth service you can drop into your app:
import 'package:supabase_flutter/supabase_flutter.dart';
class AuthService {
final _client = Supabase.instance.client;
Future<AuthResponse> signUp({
required String email,
required String password,
}) {
return _client.auth.signUp(email: email, password: password);
}
Future<AuthResponse> signIn({
required String email,
required String password,
}) {
return _client.auth.signInWithPassword(email: email, password: password);
}
Future<void> signOut() => _client.auth.signOut();
User? get currentUser => _client.auth.currentUser;
Stream<AuthState> get authStateChanges => _client.auth.onAuthStateChange;
}
A few things worth knowing:
signUpsends a confirmation email by default. Until the user clicks the link, no session is created — your app should show a "check your inbox" screen. You can disable email confirmation in Authentication → Providers → Email for internal/test builds, but keep it on for production.AuthResponsecontainssessionanduser. Ifsessionis null after sign-up, that usually means email confirmation is pending — not a bug.- Wrap these calls in try/catch and surface
AuthExceptionmessages. Supabase returns readable errors like "Invalid login credentials", which you can show directly or map to friendlier copy.
A minimal login form
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
final _email = TextEditingController();
final _password = TextEditingController();
bool _loading = false;
Future<void> _signIn() async {
setState(() => _loading = true);
try {
await AuthService().signIn(
email: _email.text.trim(),
password: _password.text,
);
} on AuthException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message)),
);
}
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
void dispose() {
_email.dispose();
_password.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
TextField(controller: _email, decoration: const InputDecoration(labelText: 'Email')),
TextField(controller: _password, obscureText: true, decoration: const InputDecoration(labelText: 'Password')),
ElevatedButton(
onPressed: _loading ? null : _signIn,
child: _loading
? const CircularProgressIndicator()
: const Text('Sign in'),
),
],
),
);
}
}
Step 4: Reacting to Auth State Changes
The SDK persists the session automatically (secure storage on mobile, local storage on web) and refreshes expired tokens without you lifting a finger. To route users based on auth state, listen to onAuthStateChange:
Supabase.instance.client.auth.onAuthStateChange.listen((data) {
final event = data.event;
if (event == AuthChangeEvent.signedIn) {
// navigate to home
} else if (event == AuthChangeEvent.signedOut) {
// navigate to login
}
});
If you use go_router, the cleanest pattern is a redirect that reads currentUser:
GoRouter(
redirect: (context, state) {
final loggedIn = Supabase.instance.client.auth.currentUser != null;
final loggingIn = state.matchedLocation == '/login';
if (!loggedIn && !loggingIn) return '/login';
if (loggedIn && loggingIn) return '/home';
return null;
},
// ...routes
);
Because onAuthStateChange fires on token refresh and session expiry too, one listener keeps your whole navigation tree honest — no polling, no manual token checks.
Step 5: Google Sign-In (OAuth)
Supabase supports OAuth providers, and Google is the one your users will ask for first. The flow:
- In the Supabase dashboard, go to Authentication → Providers → Google and enable it with your Google Cloud OAuth client ID and secret.
- Add the platform-specific setup (SHA-1 for Android, URL scheme for iOS) — the Supabase docs walk through both.
- In Flutter:
await Supabase.instance.client.auth.signInWithOAuth(
OAuthProvider.google,
redirectTo: 'com.yourapp://login-callback',
);
On mobile, this opens the system browser for the Google consent screen and deep-links back into your app. Make sure the redirect URL you pass matches a scheme registered in your AndroidManifest.xml / Info.plist and is whitelisted in the Supabase dashboard under Authentication → URL Configuration. Mismatched redirect URLs are the number one reason OAuth "doesn't work" — it is almost always configuration, not code.
Step 6: Magic Links and Password Reset
Passwordless login is one line:
await Supabase.instance.client.auth.signInWithOtp(email: email);
The user gets an email with a magic link. On mobile, handle the incoming deep link and exchange it for a session:
await Supabase.instance.client.auth.getSessionFromUrl(Uri.parse(incomingLink));
Password reset is equally short:
await Supabase.instance.client.auth.resetPasswordForEmail(email);
The user clicks the link in their email and lands on a Supabase-hosted page to set a new password — or you can point the redirect at your own in-app screen for a branded experience.
Step 7: Row Level Security — Protect Your Data
This is the step most Supabase Flutter tutorials skip, and it is the most important one. The anon key is public, so RLS policies are your actual security boundary. Suppose you have a profiles table with a user_id column:
-- Enable RLS on the table
alter table profiles enable row level security;
-- Users can read and update only their own profile
create policy "Users manage own profile"
on profiles
for all
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
The auth.uid() function returns the authenticated user's ID from their JWT. With this policy, even though the anon key is embedded in your APK, a malicious user can only ever touch their own rows. Test your policies in the dashboard's SQL editor with different roles before shipping — a table with RLS enabled but no policies is completely locked, including for your own app.
Step 8: Session Management and Sign Out
The SDK handles token refresh automatically, but you should still handle the edges:
- Listen for
signedOut/tokenRefreshedevents in your auth state listener (Step 4) so the UI reacts when a session dies. - On sign out, call
signOut()and also clear any local caches keyed to the user (Hive boxes, cached profile data) — stale data leaking across accounts is a real privacy bug in shared-device scenarios. - On web, sessions persist in localStorage. If your app runs on a shared kiosk, consider
signOut(scope: SignOutScope.local)semantics and explicit sign-out buttons rather than relying on tab close.
Common Pitfalls
| Pitfall | Fix |
|---|---|
| "Invalid API key" on startup | You pasted the service_role key or a typo'd anon key — double-check Settings → API |
| OAuth opens browser but never returns | Redirect scheme mismatch between app, code, and dashboard URL allowlist |
| Sign-up succeeds but no session | Email confirmation is enabled — expected behavior, show a "check inbox" screen |
| Queries return empty rows | RLS enabled with no matching policy — check policies in the dashboard |
currentUser is null right after hot restart |
Session restore is async — wait for the initial onAuthStateChange event |
Wrapping Up
That is the complete supabase flutter auth setup: project config, SDK initialization, email/password, OAuth, magic links, auth-state-driven routing, and the RLS policies that actually secure your data. The whole flow fits in a single AuthService class plus a redirect in your router — which is exactly why Supabase has become my default recommendation for new Flutter projects that need a real database, not just a login screen.
If you are building an app and want the auth, backend, and architecture done right from day one, get in touch — I help teams ship production Flutter apps without the usual backend detours.
Frequently Asked Questions
How do I initialize Supabase in a Flutter app?
Call Supabase.initialize with your Project URL and anon key inside main(), after WidgetsFlutterBinding.ensureInitialized() and before runApp. Put the keys in --dart-define flags or an uncommitted dotenv file, never hardcoded. After initialization the client is available anywhere as Supabase.instance.client.
Is it safe to ship the Supabase anon key in my Flutter app?
Yes. The anon key is a public key and is safe to ship in the app — row-level security (RLS) is what actually protects your data. Never ship the service_role key in a client app because it bypasses all RLS policies.
Why is there no session after sign-up in Supabase?
Sign-up sends a confirmation email by default, and no session is created until the user clicks the link, so your app should show a check-your-inbox screen. If AuthResponse.session is null after sign-up, email confirmation is usually pending, not a bug. Keep confirmation enabled for production.
How do I route users based on Supabase auth state in Flutter?
Listen to auth.onAuthStateChange, which fires events like AuthChangeEvent.signedIn and signedOut. With go_router, the cleanest pattern is a redirect that reads currentUser. The SDK persists the session automatically and refreshes expired tokens without extra code.