Authentication in Flutter with Supabase (OAuth included)
I’ve been working on a Flutter app for a while and recently decided to add authentication. Setting it up correctly is challenging, especially when it comes to managing backend infrastructure and administration. So, there are several common approaches to implementing authentication, including:
- Building a custom authentication service
- Using a cloud-based service like Auth0, Firebase, or Okta
- Deploying a self-hosted solution like Keycloak or Supabase
After carefully evaluating these options, I realized that creating a custom authentication service and managing the infrastructure would consume a lot of time — something I wanted to avoid, especially for an MVP.
This led me to look at pre-built solutions. I initially considered Firebase, Auth0, and Clerk, but found their pricing prohibitive and noticed that some aren’t fully supported in Flutter (for example, Firebase isn’t available on Desktop).
Then, I discovered Supabase, an open-source Firebase alternative that is written in pure Dart. In this article, I’ll cover how to implement Authentication (with OAuth via Google) using Supabase, along with setting up a robust architecture that avoids tight coupling. This is going to be useful even if you do not use Supabase as I’ll walk through creating guards, an authentication data provider, and a BLoC to manage the flow.
Outline
Auth Provider
Our journey starts with the data provider, the foundation of the app’s architecture. The data layer handles data exchange, specifically in this case, by communicating with Supabase. If you're not familiar with layered architecture, check out my Flutter App Architecture series.
Here’s the AuthProvider interface:
/// {@template auth_provider}
/// Provides methods to authenticate users.
/// {@endtemplate}
abstract interface class AuthProvider {
/// Stream of the session state.
///
/// If session is null, user is not authenticated.
Stream<SessionEntity?> get sessionStream;
/// Loads the session.
Future<SessionEntity?> loadSession();
/// Sign in with Google.
///
/// The event is delivered later in [sessionStream], when user comes back from the Google sign in process.
Future<void> signInWithGoogle();
/// Sign up with email and password.
///
/// The event is also delivered later in [sessionStream], when user is signed up.
Future<SessionEntity?> signUpWithEmailAndPassword(String email, String password);
/// Sign in with email and password.
///
/// The event is also delivered later in [sessionStream], when user is signed in.
Future<SessionEntity?> signInWithEmailAndPassword(String email, String password);
/// Refreshes the session.
Future<SessionEntity?> refreshSession();
/// Sign out.
Future<void> signOut();
}AuthProvider interface
The AuthProvider interface includes methods needed for authentication management in the app. Here’s a breakdown:
sessionStream: Returns a stream of session changes, notifying listeners whenever the authentication state updates. For instance, a successful sign-in emits a populated session model, indicating the user is authenticated and can access protected resources.loadSession: Loads a persisted session, typically used during app restarts, in authentication request interceptors, or for refreshing tokens.refreshSession: Retrieves a new session from the current one. Each session includes an access token and a refresh token. When the access token expires, the refresh token is used to renew it.signInWithGoogle: Handles signing in with Google. It returns void because the OAuth process requires opening an external browser, so the session is updated asynchronously when the user returns to the app.signUpWithEmailAndPasswordandsignInWithEmailAndPassword: Create an account and log in using an email-password pair.signOut: Signs the user out and invalidates their tokens.
Session and User
To avoid being tightly coupled to a specific service or implementation, it’s essential to create custom models abstracted from service-specific ones. Here are some example implementations for our use case:
/// {@template session_entity}
/// Entity of a session.
/// {@endtemplate}
class SessionEntity {
/// {@macro session_entity}
SessionEntity({
required this.accessToken,
required this.refreshToken,
required this.user,
});
/// Access token.
final String accessToken;
/// Refresh token.
final String? refreshToken;
/// User entity.
final UserEntity user;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SessionEntity &&
runtimeType == other.runtimeType &&
accessToken == other.accessToken &&
refreshToken == other.refreshToken &&
user == other.user;
@override
int get hashCode => accessToken.hashCode ^ refreshToken.hashCode ^ user.hashCode;
}
/// {@template user_entity}
/// Entity of authenticated user.
/// {@endtemplate}
class UserEntity {
/// {@macro user_entity}
UserEntity({
required this.id,
this.email,
});
/// User id.
final String id;
/// User email.
final String? email;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is UserEntity &&
runtimeType == other.runtimeType &&
id == other.id &&
email == other.email;
@override
int get hashCode => id.hashCode ^ email.hashCode;
@override
String toString() => 'UserEntity(id: $id, email: $email)';
}Session and User models
Implementation for Supabase
With the interface and models in place, we can now create an implementation that uses Supabase for authentication. Here’s how I implemented it:
/// {@template auth_provider_supabase}
/// AuthProvider implementation for Supabase.
/// {@endtemplate}
final class AuthProviderSupabase implements AuthProvider {
/// {@macro auth_provider_supabase}
AuthProviderSupabase({
required this.client,
required this.oauthRedirectUri,
});
/// Supabase Client instance.
final supabase.SupabaseClient client;
/// OAuth redirect URI.
final String oauthRedirectUri;
@override
Stream<SessionEntity?> get sessionStream => client.auth.onAuthStateChange.map(
(event) {
final session = _mapSessionToEntity(event.session);
return session;
},
);
@override
Future<SessionEntity?> loadSession() async {
final session = client.auth.currentSession;
return _mapSessionToEntity(session);
}
@override
Future<void> signInWithGoogle() async {
final oAuthUrl = await client.auth.getOAuthSignInUrl(
provider: supabase.OAuthProvider.google,
redirectTo: oauthRedirectUri,
);
await launchUrl(Uri.parse(oAuthUrl.url));
}
@override
Future<SessionEntity?> signInWithEmailAndPassword(String email, String password) async {
try {
final response = await client.auth.signInWithPassword(
email: email,
password: password,
);
return _mapSessionToEntity(response.session);
} on supabase.AuthException catch (e, stackTrace) {
final exception = _mapAuthException(e);
Error.throwWithStackTrace(exception, stackTrace);
}
}
@override
Future<SessionEntity?> signUpWithEmailAndPassword(String email, String password) async {
try {
final response = await client.auth.signUp(email: email, password: password);
return _mapSessionToEntity(response.session);
} on supabase.AuthException catch (e, stackTrace) {
final exception = _mapAuthException(e);
Error.throwWithStackTrace(exception, stackTrace);
}
}
@override
Future<void> signOut() async => await client.auth.signOut();
@override
Future<SessionEntity?> refreshSession() async {
final response = await client.auth.refreshSession();
return _mapSessionToEntity(response.session);
}
}AuthProviderSupabase implementation
The AuthProviderSupabase class implements the AuthProvider interface using Supabase. It maps Supabase-specific models to application-wide entities:
SessionEntity? _mapSessionToEntity(supabase.Session? session) {
if (session == null) return null;
return SessionEntity(
accessToken: session.accessToken,
refreshToken: session.refreshToken,
user: UserEntity(
id: session.user.id,
email: session.user.email,
),
);
}Another responsibility of the data layer is to map service-specific exceptions to application-recognized ones. In AuthProviderSupabase, Supabase’s AuthException is caught and converted into an AuthException that the app understands:
AuthException _mapAuthException(supabase.AuthException e) => switch (e.code) {
'invalid_credentials' => AuthExceptionInvalidCredentials(message: e.message),
'validation_failed' => AuthExceptionValidationFailed(message: e.message),
'weak_password' => AuthExceptionWeakPassword(message: e.message),
'user_already_exists' => AuthExceptionUserAlreadyExists(message: e.message),
_ => AuthExceptionUnknown(message: '${e.message} (${e.code})', cause: e),
};This class is straightforward, but there’s one notable aspect: the signInWithGoogle method. Unlike the others, it opens a browser app for the user to log into their Google account. Once the login is successful, the user is redirected back to the app via a deep link containing the token. This behavior is handled by supabase internally (i.e. catching the deep link and notifying about state changes) and in case of success a new session is delivered via the sessionStream.
At this point you may set up Google Sign-In, as it requires configuration on both the mobile and server sides. These links might help:

Auth Bloc and Scope
Now, it’s time to implement the business logic and state management. I’ll use bloc for this, which will handle communication with the AuthProvider and update the state accordingly. This is how it looks like:
/// {@template auth_bloc}
/// Authentication bloc.
/// {@endtemplate}
final class AuthBloc extends Bloc<AuthEvent, AuthState> {
/// Creates an authentication bloc.
AuthBloc({
required AuthProvider authProvider,
required AuthState initialState,
}) : _authProvider = authProvider,
super(initialState) {
on<AuthEvent>(
(event, emit) => switch (event) {
final AuthEventSignInWithGoogle event => _signInWithGoogle(event, emit),
final AuthEventSignInWithEmailAndPassword event => _signInWithEmailAndPassword(event, emit),
final AuthEventSignUpWithEmailAndPassword event => _signUpWithEmailAndPassword(event, emit),
final UpdateSessionEvent event => _updateSession(event, emit),
final AuthEventSignOut event => _signOut(event, emit),
},
);
// Each time when session changes, update the session state
_authProvider.sessionStream.listen((session) {
add(UpdateSessionEvent(session));
});
}
final AuthProvider _authProvider;
Future<void> _signInWithGoogle(
AuthEventSignInWithGoogle event,
Emitter<AuthState> emit,
) async {
emit(AuthProcessingState(event: event, session: state.session));
try {
await _authProvider.signInWithGoogle();
emit(AuthIdleState(event: event, session: state.session));
} on Object catch (error, stackTrace) {
emit(AuthErrorState(error: error, event: event, session: state.session));
onError(error, stackTrace);
} finally {
emit(AuthIdleState(event: event, session: state.session));
}
}
Future<void> _signInWithEmailAndPassword(
AuthEventSignInWithEmailAndPassword event,
Emitter<AuthState> emit,
) async {
emit(AuthProcessingState(event: event, session: state.session));
try {
final newSession = await _authProvider.signInWithEmailAndPassword(
event.email,
event.password,
);
emit(AuthIdleState(event: event, session: newSession));
} on AuthException catch (error) {
emit(AuthErrorState(error: error, event: event, session: state.session));
} on Object catch (error, stackTrace) {
emit(AuthErrorState(error: error, event: event, session: state.session));
onError(error, stackTrace);
} finally {
emit(AuthIdleState(event: event, session: state.session));
}
}
Future<void> _updateSession(
UpdateSessionEvent event,
Emitter<AuthState> emit,
) async {
emit(AuthProcessingState(event: event, session: state.session));
try {
emit(AuthIdleState(event: event, session: event.session));
} on Object catch (error, stackTrace) {
emit(AuthErrorState(error: error, event: event, session: state.session));
onError(error, stackTrace);
} finally {
emit(AuthIdleState(event: event, session: state.session));
}
}
}AuthBloc implementation
This bloc includes handlers for each authentication method from the AuthProvider. Each handler uses try/catch blocks to catch potential exceptions. If an exception arises, it checks for known (application-wide) exceptions and doesn’t report them, as they’re expected and considered normal behavior.
There’s also a special event called UpdateSessionEvent, used internally by the bloc when a session update happens. The _updateSession handler simply emits the new session state.
Initialization
Authentication is a feature best initialized at app startup, allowing the app to know right away whether the user is authenticated or not. In my Sizzle Starter template I have an established approach for initializing dependencies. Take a look here.
The initialization factory for the bloc would look like this:
/// A factory class that creates [AuthBloc]
class AuthBlocFactory extends AsyncFactory<AuthBloc> {
/// Creates an instance of [AuthBlocFactory].
AuthBlocFactory({
required this.sharedPreferences,
required this.supabaseClient,
required this.config,
});
/// Shared preferences instance.
final SharedPreferencesAsync sharedPreferences;
/// Supabase client instance.
final supabase.SupabaseClient supabaseClient;
/// Application configuration
final Config config;
@override
Future<AuthBloc> init() async {
final authProvider = AuthProviderSupabase(
client: supabaseClient,
oauthRedirectUri: config.oauthAppRedirectUri,
);
final session = await authProvider.loadSession();
return AuthBloc(
authProvider: authProvider,
initialState: AuthIdleState(session: session),
);
}
}In addition to creating the bloc instance, it loads the session and sets the initial state. This ensures that if the user is already authenticated, they go straight into the app upon startup, skipping any unnecessary loading screens.
Auth Scope
To create a cleaner API for handling authentication and to prepare for future navigation logic, we need to set up a scope that uses the auth bloc under the hood:
class AuthScope extends StatefulWidget {
/// {@macro auth_scope}
const AuthScope({
required this.child,
super.key, // ignore: unused_element
});
/// The widget below this widget in the tree.
final Widget child;
/// Returns the current [AuthScopeState].
static AuthScopeState of(
BuildContext context, {
bool listen = true,
}) =>
context.inhOf<_AuthInherited>(listen: listen).state;
/// Returns the bloc instance.
static AuthBloc blocOf(BuildContext context, {bool listen = true}) =>
of(context, listen: listen).authBloc;
/// Returns the current [AuthState].
static AuthState stateOf(BuildContext context, {bool listen = true}) =>
of(context, listen: listen)._authState;
@override
State<AuthScope> createState() => AuthScopeState();
}
/// State for widget AuthScope.
class AuthScopeState extends State<AuthScope> {
StreamSubscription<AuthState>? _subscription;
/// The auth bloc instance
late final AuthBloc authBloc;
/// Auth state.
AuthState get authState => _authState;
late AuthState _authState;
@override
void initState() {
super.initState();
// get the initialized auth bloc instance
authBloc = DependenciesScope.of(context).authBloc;
_authState = authBloc.state;
_subscription = authBloc.stream.listen((state) {
setState(() => _authState = state);
});
}
@override
void dispose() {
_subscription?.cancel();
super.dispose();
}
/* Helper Methods */
/// Signs in with Google.
void signInWithGoogle() => authBloc.add(const AuthEventSignInWithGoogle());
/// Signs out.
void signOut() => authBloc.add(const AuthEventSignOut());
@override
Widget build(BuildContext context) => _AuthInherited(
state: this,
authState: _authState,
child: widget.child,
);
}
/// {@template auth_inherited}
/// _AuthInherited widget.
/// {@endtemplate}
class _AuthInherited extends InheritedWidget {
/// {@macro auth_inherited}
const _AuthInherited({
required this.state,
required this.authState,
required super.child,
super.key, // ignore: unused_element
});
final AuthScopeState state;
final AuthState authState;
@override
bool updateShouldNotify(covariant _AuthInherited oldWidget) {
final shouldNotify = authState != oldWidget.authState;
return shouldNotify;
}
}This scope holds the authentication state and provides helper methods, allowing widgets to avoid dealing with bloc specifics. Clients of AuthScope can access the AuthBloc using blocOf, the authentication state with stateOf, and the scope’s state through the of method. Here's how we can use this scope:
final authState = AuthScope.stateOf(context);
print(authState?.session != null ? "authenticated" : "not authenticated");
final bloc = AuthScope.blocOf(context);
bloc.listen(); // listen for changes ...The last step is to wrap the root of your application with AuthScope.
Navigation
In this section we're going to implement the navigation between screens and secure them with guards.
Login and Signup Screens
I’ve created two authentication screens for the app: Login and Signup. The Login screen includes text fields for email and password, along with buttons to sign in using either email/password or Google:
/// {@template login_screen}
/// LoginScreen widget.
/// {@endtemplate}
class LoginScreen extends StatefulWidget {
/// {@macro login_screen}
const LoginScreen({
super.key, // ignore: unused_element
});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
bool _obscuringPassword = true;
late final _emailController = TextEditingController();
late final _passwordController = TextEditingController();
late final _authBloc = AuthScope.blocOf(context, listen: false);
late final _formListenable = Listenable.merge([
_emailController,
_passwordController,
]);
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
void _signInWithGoogle() => AuthScope.of(context, listen: false).signInWithGoogle();
void _signInWithEmailAndPassword() =>
AuthScope.of(context, listen: false).signInWithEmailAndPassword(
email: _emailController.text,
password: _passwordController.text,
);
String _localizeError(Object error) => switch (error) {
final AuthException authException => localizeAuthException(context, authException),
_ => Localization.of(context).unknown_exception_happened(error.toString()),
};
String? _errorText;
@override
Widget build(BuildContext context) {
final layout = WindowSizeScope.of(context);
final l10n = Localization.of(context);
return BlocConsumer<AuthBloc, AuthState>(
bloc: _authBloc,
listener: (context, state) {
if (state.error case final Object error
when state.event is AuthEventSignInWithEmailAndPassword) {
_errorText = _localizeError(error);
}
},
builder: (context, authState) => Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
body: Center(
child: SingleChildScrollView(
padding: HorizontalSpacing.centered(layout.width, 512).copyWith(
top: 32,
bottom: 32,
),
child: SizedBox(
width: 512,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
UiText.headlineSmall(l10n.sign_in_to_your_account),
const SizedBox(height: 24),
UiTextField.standard(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
style: UiTextFieldStyle(
labelText: l10n.email,
hintText: l10n.enter_email,
prefixIcon: const Icon(Icons.email),
),
),
const SizedBox(height: 16),
UiTextField.standard(
controller: _passwordController,
obscureText: _obscuringPassword,
keyboardType: TextInputType.visiblePassword,
style: UiTextFieldStyle(
labelText: l10n.password,
hintText: l10n.enter_password,
prefixIcon: const Icon(Icons.lock),
suffixIcon: UiButton.icon(
icon: Icon(_obscuringPassword ? Icons.visibility : Icons.visibility_off),
onPressed: () {
setState(() => _obscuringPassword = !_obscuringPassword);
},
),
),
),
if (_errorText case final String error) ...[
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: UiText.bodyMedium(
error,
color: Theme.of(context).colorScheme.error,
),
),
],
const SizedBox(height: 16),
ListenableBuilder(
listenable: _formListenable,
builder: (context, _) => SizedBox(
height: 48,
width: double.infinity,
child: UiButton.filledPrimary(
label: authState.isProcessing
? const UiProgressIndicator()
: Text(l10n.sign_in),
enabled: _emailController.text.isNotEmpty &&
_passwordController.text.isNotEmpty &&
!authState.isProcessing,
onPressed: _signInWithEmailAndPassword,
),
),
),
if (Theme.of(context).platform != TargetPlatform.windows) ...[
const SizedBox(height: 24),
UiButton.text(
icon: const Icon(Icons.g_mobiledata, size: 48),
label: Text(l10n.sign_in_with_google),
onPressed: _signInWithGoogle,
),
],
const UiDivider(height: 32),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
UiText.bodyMedium(l10n.don_t_have_an_account),
const SizedBox(width: 8),
UiButton.text(
label: Text(l10n.sign_up),
onPressed: () {
const SignupRoute().go(context);
},
),
],
),
],
),
),
),
),
),
);
}
}
LoginScreen
The login screen includes basic validation and uses a scope to send appropriate login events. It listens for state changes in bloc and optionally displays errors. What’s interesting is how errors are localized: I’ve created a method that returns a specific error message string based on the type of exception:
/// Localizes the [AuthException].
String localizeAuthException(BuildContext context, AuthException exception) {
final l10n = Localization.of(context);
return switch (exception) {
AuthExceptionInvalidCredentials _ => l10n.invalid_credentials_entered,
AuthExceptionValidationFailed _ => l10n.validation_failed,
AuthExceptionWeakPassword _ => l10n.weak_password,
AuthExceptionUserAlreadyExists _ => l10n.user_already_exists,
final AuthExceptionUnknown exception => l10n.exception_happened(
'${exception.message}',
),
};
}Routes
The signup screen is similar to the login screen. Now, we need to set up the routes. I’ll use go_router for this, but feel free to use any routing package you prefer:
/// {@template login_route}
/// A route for the login screen.
/// {@endtemplate}
@TypedGoRoute<LoginRoute>(path: '/login')
class LoginRoute extends GoRouteData {
/// {@macro login_route}
const LoginRoute();
@override
Widget build(BuildContext context, GoRouterState state) => const LoginScreen();
}
/// {@template signup_route}
/// A route for the signup screen.
/// {@endtemplate}
@TypedGoRoute<SignupRoute>(path: '/signup')
class SignupRoute extends GoRouteData {
/// {@macro signup_route}
const SignupRoute();
@override
Widget build(BuildContext context, GoRouterState state) => const SignupScreen();
}Guards
Since everything is set up, let’s add authentication guards:
final _authMatchPattern =
RegExp('^(${const LoginRoute().location}|${const SignupRoute().location})\$');
/// A guard that redirects users from the login screen if they are authenticated.
final class RedirectIfAuthenticatedGuard extends RedirectGuard {
@override
Pattern get matchPattern => _authMatchPattern;
@override
String? redirect(BuildContext context, GoRouterState state) {
final isAuthenticated = AuthScope.stateOf(context).session != null;
if (isAuthenticated) {
return const GenerateRoute().location;
}
return null;
}
}
/// A guard that redirects users to the login screen if they are not authenticated.
final class RedirectIfNotAuthenticatedGuard extends RedirectGuard {
@override
Pattern get matchPattern => _authMatchPattern;
@override
bool get invertMatch => true;
@override
String? redirect(BuildContext context, GoRouterState state) {
final isAuthenticated = AuthScope.stateOf(context).session != null;
if (!isAuthenticated) {
return const LoginRoute().location;
}
return null;
}
}
The RedirectIfAuthenticatedGuard should redirect any authenticated users to the "/generate" route, which serves as the home route of the application.
The RedirectIfNotAuthenticatedGuard should redirect unauthenticated users to the "/login" route when they try to access secure routes. For example, if a user tries to access "/generate" without being authenticated, they will be redirected to "/login".
The guards monitor changes in authentication state and trigger the appropriate redirects. This works because AuthScope relies on an inherited widget, creating a dependency that causes the guards to re-evaluate whenever the auth state changes. For example, if an unauthenticated user successfully signs in and the auth BLoC emits a state with an active session, RedirectIfAuthenticatedGuard is notified by the inherited widget and returns the "/generate" path, redirecting the user to the generate screen after login.
The sources of RedirectGuard and RedirectBuilder can be found here.
Final Results
Supabase looks very promising so far. I’ll see how it performs when I create my own backend routes and secure them with Supabase tokens. I’ll keep you posted—stay tuned!
By the way, this is how the final result looks like:


