Multi-factor authentication or MFA is an essential part of security for any kind of app.
We will take a look at an example app where a user has to sign in via MFA in order to view the contents of the app to demonstrate how easy it is to get started with MFA on Flutter.
What is Multi-Factor Authentication?
Multi-factor authentication (MFA), sometimes called two-factor authentication (2FA), is an additional security layer on top of traditional login methods such as email and password login.
There are several forms of MFA, such as with an SMS or through using an authenticator app such as Google Authenticator. It is considered a best practice to use MFA whenever possible because it protects users against weak passwords or compromised social accounts.
Why Multi-Factor Authentication matters for Flutter apps
In the context of Flutter apps, MFA is important because it helps protect sensitive user data and prevent unauthorized access to user accounts. By requiring users to provide an additional factor, MFA adds an extra layer of security that makes it harder for attackers to gain access to user accounts.
Given how Flutter is widely used MFA might be a requirement rather than a nice-to-have. Implementing MFA in a Flutter app can improve overall security and give users peace of mind knowing that their data is better protected.
Building the App
We are building a simple app where users register with an email and password. After completing the registration process, the users will be asked to set up MFA using an authenticator app. Once verifying the identity via the authenticator app, the user can go to the home page where they can view the main content.
Login works similarly, where after an email and password login, they are asked to enter the verification code to complete the login process.
The app will have the following directory structure, where auth
contains any basic auth-related pages, mfa
contains enrolling and verifying the MFA, and we have some additional pages for us to see that MFA is working correctly.
You can find the complete code created in this article here.
Step 1: Setup the scenes
Let’s start with the flutter create
command.
_10flutter create mfa_app
Also, if you do not have a Supabase project yet, create one by heading to database.new. Within a few minutes, you will have a new Supabase project.
Step 2: Add the dependencies
Install the supabase_flutter package by running the following command in your terminal.
_10dart pub add supabase_flutter
Then update your lib/main.dart
file to initialize Supabase in the main function. You should be able to find your Supabase URL and AnonKey from the settings -> api
section of your dashboard. We will also extract the SupabaseClient
for easy access to our Supabase instance.
_13import 'package:flutter/material.dart';_13import 'package:supabase_flutter/supabase_flutter.dart';_13_13void main() async {_13 await Supabase.initialize(_13 url: 'SUPABASE_URL',_13 anonKey: 'SUPABASE_ANONKEY',_13 );_13 runApp(const MyApp());_13}_13_13/// Extract SupabaseClient instance in a handy variable_13final supabase = Supabase.instance.client;
Also, add go_router to handle our routing and redirects.
_10dart pub add go_router
We will set up the routes towards the end when we have created all the pages we need. With this, we are ready to jump into creating the app.
Also, if we want to support iOS and Android, we need to set up deep links so that a session can be obtained upon clicking on the confirmation link sent to the user’s email address.
We will configure it so that we can open the app by redirecting to mfa-app://callback
.
For iOS, open ios/Runner/info.plist
file and add the following deep link configuration.
_24<!-- ... other tags -->_24 <plist>_24 <dict>_24 <!-- ... other tags -->_24_24 <!-- Deep Links -->_24 <key>FlutterDeepLinkingEnabled</key>_24 <true/>_24 <key>CFBundleURLTypes</key>_24 <array>_24 <dict>_24 <key>CFBundleTypeRole</key>_24 <string>Editor</string>_24 <key>CFBundleURLSchemes</key>_24 <array>_24 <string>mfa-app</string>_24 </array>_24 </dict>_24 </array>_24 <!-- Deep Links -->_24_24 <!-- ... other tags -->_24 </dict>_24 </plist>
For Android, open android/app/src/main/AndroidManifest.xml
file and add the following deep link configuration.
_21<manifest ...>_21 <!-- ... other tags -->_21 <application ...>_21 <activity ...>_21 <!-- ... other tags -->_21_21 <!-- Deep Links -->_21 <meta-data android:name="flutter_deeplinking_enabled" android:value="true" />_21 <intent-filter>_21 <action android:name="android.intent.action.VIEW" />_21 <category android:name="android.intent.category.DEFAULT" />_21 <category android:name="android.intent.category.BROWSABLE" />_21 <data_21 android:scheme="mfa-app"_21 android:host="callback" />_21 </intent-filter>_21 <!-- END Deep Links -->_21_21 </activity>_21 </application>_21 </manifest>
After, we will add the deep link as one of the redirect URLs in our Supabase dashboard.
Go to Authentication > URL Configuration
and add mfa-app://callback/*
as a redirect URL. Make sure you don’t add any extra slashes or anything because if you do, deep linking will not work properly.
Lastly, we will add the flutter_svg
package. This package will later be used to display a QR code to scan with their authentication app.
_10dart pub add flutter_svg
That is all the dependencies that we need. Let’s dive into coding!
Step 3: Create the signup flow
Let’s first create a signup flow. Again, the user will register with the app using email and password, and after confirming their email address, they will enroll in MFA using an authenticator app.
The register page contains a form with an email and password field for the user to create a new account. We are just calling the .signUp() method with it.
As you can see in the code below at emailRedirectTo
option of the .signUp()
method, upon clicking on the confirmation link sent to the user, they will be taken to MFA enrollment page, which we will implement later.
Create a lib/pages/auth/signup_page.dart
file and add the following. There will be some errors, but that is because we haven’t created some of the files yet. The errors will go away as we move on, so ignore them for now.
_101import 'package:flutter/material.dart';_101import 'package:go_router/go_router.dart';_101import 'package:mfa_app/main.dart';_101import 'package:mfa_app/pages/auth/login_page.dart';_101import 'package:mfa_app/pages/mfa/enroll_page.dart';_101import 'package:supabase_flutter/supabase_flutter.dart';_101_101class RegisterPage extends StatefulWidget {_101 static const route = '/auth/register';_101_101 const RegisterPage({super.key});_101_101 @override_101 State<RegisterPage> createState() => _RegisterPageState();_101}_101_101class _RegisterPageState extends State<RegisterPage> {_101 final _emailController = TextEditingController();_101 final _passwordController = TextEditingController();_101_101 bool _isLoading = false;_101_101 @override_101 void dispose() {_101 _emailController.dispose();_101 _passwordController.dispose();_101 super.dispose();_101 }_101_101 @override_101 Widget build(BuildContext context) {_101 return Scaffold(_101 appBar: AppBar(title: const Text('Register')),_101 body: ListView(_101 padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),_101 children: [_101 TextFormField(_101 controller: _emailController,_101 decoration: const InputDecoration(_101 label: Text('Email'),_101 ),_101 ),_101 const SizedBox(height: 16),_101 TextFormField(_101 controller: _passwordController,_101 decoration: const InputDecoration(_101 label: Text('Password'),_101 ),_101 obscureText: true,_101 ),_101 const SizedBox(height: 16),_101 ElevatedButton(_101 onPressed: () async {_101 try {_101 setState(() {_101 _isLoading = true;_101 });_101 final email = _emailController.text.trim();_101 final password = _passwordController.text.trim();_101 await supabase.auth.signUp(_101 email: email,_101 password: password,_101 emailRedirectTo:_101 'mfa-app://callback${MFAEnrollPage.route}', // redirect the user to setup MFA page after email confirmation_101 );_101 if (mounted) {_101 ScaffoldMessenger.of(context).showSnackBar(_101 const SnackBar(content: Text('Check your inbox.')));_101 }_101 } on AuthException catch (error) {_101 ScaffoldMessenger.of(context)_101 .showSnackBar(SnackBar(content: Text(error.message)));_101 } catch (error) {_101 ScaffoldMessenger.of(context).showSnackBar(_101 const SnackBar(content: Text('Unexpected error occurred')));_101 }_101 if (mounted) {_101 setState(() {_101 _isLoading = false;_101 });_101 }_101 },_101 child: _isLoading_101 ? const SizedBox(_101 height: 24,_101 width: 24,_101 child: Center(_101 child: CircularProgressIndicator(color: Colors.white)),_101 )_101 : const Text('Register'),_101 ),_101 const SizedBox(height: 16),_101 TextButton(_101 onPressed: () => context.push(LoginPage.route),_101 child: const Text('I already have an account'),_101 )_101 ],_101 ),_101 );_101 }_101}
We can then create the enrollment page for MFA. This page is taking care of the following.
- Retrieve the enrollment secret from the server via
supabase.auth.mfa.enroll()
method. - Displaying the secret and its QR code representation and prompts the user to add the app to their authenticator app
- Verifies the user with a TOTP
The QR code and the secret will be displayed automatically when the page loads. When the user enters the correct 6-digit TOTP, they will be automatically redirected to the home page.
Create lib/pages/mfa/enroll_page.dart
file and add the following.
_135import 'package:flutter/material.dart';_135import 'package:flutter/services.dart';_135import 'package:flutter_svg/flutter_svg.dart';_135import 'package:go_router/go_router.dart';_135import 'package:mfa_app/main.dart';_135import 'package:mfa_app/pages/auth/register_page.dart';_135import 'package:mfa_app/pages/home_page.dart';_135import 'package:supabase_flutter/supabase_flutter.dart';_135_135class MFAEnrollPage extends StatefulWidget {_135 static const route = '/mfa/enroll';_135 const MFAEnrollPage({super.key});_135_135 @override_135 State<MFAEnrollPage> createState() => _MFAEnrollPageState();_135}_135_135class _MFAEnrollPageState extends State<MFAEnrollPage> {_135 final _enrollFuture = supabase.auth.mfa.enroll();_135_135 @override_135 Widget build(BuildContext context) {_135 return Scaffold(_135 appBar: AppBar(_135 title: const Text('Setup MFA'),_135 actions: [_135 TextButton(_135 onPressed: () {_135 supabase.auth.signOut();_135 context.go(RegisterPage.route);_135 },_135 child: Text(_135 'Logout',_135 style: TextStyle(color: Theme.of(context).colorScheme.onPrimary),_135 ),_135 ),_135 ],_135 ),_135 body: FutureBuilder(_135 future: _enrollFuture,_135 builder: (context, snapshot) {_135 if (snapshot.hasError) {_135 return Center(child: Text(snapshot.error.toString()));_135 }_135 if (!snapshot.hasData) {_135 return const Center(child: CircularProgressIndicator());_135 }_135_135 final response = snapshot.data!;_135 final qrCodeUrl = response.totp.qrCode;_135 final secret = response.totp.secret;_135 final factorId = response.id;_135_135 return ListView(_135 padding: const EdgeInsets.symmetric(_135 horizontal: 20,_135 vertical: 24,_135 ),_135 children: [_135 const Text(_135 'Open your authentication app and add this app via QR code or by pasting the code below.',_135 style: TextStyle(_135 fontWeight: FontWeight.bold,_135 ),_135 ),_135 const SizedBox(height: 16),_135 SvgPicture.string(_135 qrCodeUrl,_135 width: 150,_135 height: 150,_135 ),_135 const SizedBox(height: 16),_135 Row(_135 children: [_135 Expanded(_135 child: Text(_135 secret,_135 style: const TextStyle(_135 fontWeight: FontWeight.bold,_135 fontSize: 18,_135 ),_135 ),_135 ),_135 IconButton(_135 onPressed: () {_135 Clipboard.setData(ClipboardData(text: secret));_135 ScaffoldMessenger.of(context).showSnackBar(const SnackBar(_135 content: Text('Copied to your clip board')));_135 },_135 icon: const Icon(Icons.copy),_135 ),_135 ],_135 ),_135 const SizedBox(height: 16),_135 const Text('Enter the code shown in your authentication app.'),_135 const SizedBox(height: 16),_135 TextFormField(_135 decoration: const InputDecoration(_135 hintText: '000000',_135 ),_135 style: const TextStyle(fontSize: 24),_135 textAlign: TextAlign.center,_135 keyboardType: TextInputType.number,_135 onChanged: (value) async {_135 if (value.length != 6) return;_135_135 // kick off the verification process once 6 characters are entered_135 try {_135 final challenge =_135 await supabase.auth.mfa.challenge(factorId: factorId);_135 await supabase.auth.mfa.verify(_135 factorId: factorId,_135 challengeId: challenge.id,_135 code: value,_135 );_135 await supabase.auth.refreshSession();_135 if (mounted) {_135 context.go(HomePage.route);_135 }_135 } on AuthException catch (error) {_135 ScaffoldMessenger.of(context)_135 .showSnackBar(SnackBar(content: Text(error.message)));_135 } catch (error) {_135 ScaffoldMessenger.of(context).showSnackBar(const SnackBar(_135 content: Text('Unexpected error occurred')));_135 }_135 },_135 ),_135 ],_135 );_135 },_135 ),_135 );_135 }_135}
Step 4: Creating the login flow
Now that we have created a registration flow, we can get to the login flow for returning existing users. Again, the login page has nothing fancy going. We are just collecting the user’s email and password, and calling the good old .signInWithPassword() method. Upon signing in, the user will be taken to a verify page where the user will then enter their verification code from their authenticator app.
Create lib/pages/auth/login_page.dart
and add the following.
_75import 'package:flutter/material.dart';_75import 'package:go_router/go_router.dart';_75import 'package:mfa_app/main.dart';_75import 'package:mfa_app/pages/mfa/verify_page.dart';_75import 'package:supabase_flutter/supabase_flutter.dart';_75_75class LoginPage extends StatefulWidget {_75 static const route = '/auth/login';_75_75 const LoginPage({super.key});_75_75 @override_75 State<LoginPage> createState() => _LoginPageState();_75}_75_75class _LoginPageState extends State<LoginPage> {_75 final _emailController = TextEditingController();_75 final _passwordController = TextEditingController();_75_75 @override_75 void dispose() {_75 _emailController.dispose();_75 _passwordController.dispose();_75 super.dispose();_75 }_75_75 @override_75 Widget build(BuildContext context) {_75 return Scaffold(_75 appBar: AppBar(title: const Text('Login')),_75 body: ListView(_75 padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),_75 children: [_75 TextFormField(_75 controller: _emailController,_75 decoration: const InputDecoration(_75 label: Text('Email'),_75 ),_75 ),_75 const SizedBox(height: 16),_75 TextFormField(_75 controller: _passwordController,_75 decoration: const InputDecoration(_75 label: Text('Password'),_75 ),_75 obscureText: true,_75 ),_75 const SizedBox(height: 16),_75 ElevatedButton(_75 onPressed: () async {_75 try {_75 final email = _emailController.text.trim();_75 final password = _passwordController.text.trim();_75 await supabase.auth.signInWithPassword(_75 email: email,_75 password: password,_75 );_75 if (mounted) {_75 context.go(MFAVerifyPage.route);_75 }_75 } on AuthException catch (error) {_75 ScaffoldMessenger.of(context)_75 .showSnackBar(SnackBar(content: Text(error.message)));_75 } catch (error) {_75 ScaffoldMessenger.of(context).showSnackBar(_75 const SnackBar(content: Text('Unexpected error occurred')));_75 }_75 },_75 child: const Text('Login'),_75 ),_75 ],_75 ),_75 );_75 }_75}
Once a returning user logs in, they are taken to the verification page where they are asked to enter the TOTP from their authenticator app.
This verification page has the same text field as the enrollment page, and upon entering the code, they are taken to the home page.
Create a lib/pages/mfa/verify_page.dart
file and add the following.
_88import 'package:flutter/material.dart';_88import 'package:go_router/go_router.dart';_88import 'package:mfa_app/main.dart';_88import 'package:mfa_app/pages/auth/register_page.dart';_88import 'package:mfa_app/pages/home_page.dart';_88import 'package:supabase_flutter/supabase_flutter.dart';_88_88class MFAVerifyPage extends StatefulWidget {_88 static const route = '/mfa/verify';_88 const MFAVerifyPage({super.key});_88_88 @override_88 State<MFAVerifyPage> createState() => _MFAVerifyPageState();_88}_88_88class _MFAVerifyPageState extends State<MFAVerifyPage> {_88 @override_88 Widget build(BuildContext context) {_88 return Scaffold(_88 appBar: AppBar(_88 title: const Text('Verify MFA'),_88 actions: [_88 TextButton(_88 onPressed: () {_88 supabase.auth.signOut();_88 context.go(RegisterPage.route);_88 },_88 child: Text(_88 'Logout',_88 style: TextStyle(color: Theme.of(context).colorScheme.onPrimary),_88 ),_88 ),_88 ],_88 ),_88 body: ListView(_88 padding: const EdgeInsets.symmetric(_88 horizontal: 20,_88 vertical: 24,_88 ),_88 children: [_88 Text(_88 'Verification Required',_88 style: Theme.of(context).textTheme.titleLarge,_88 ),_88 const SizedBox(height: 16),_88 const Text('Enter the code shown in your authentication app.'),_88 const SizedBox(height: 16),_88 TextFormField(_88 decoration: const InputDecoration(_88 hintText: '000000',_88 ),_88 style: const TextStyle(fontSize: 24),_88 textAlign: TextAlign.center,_88 keyboardType: TextInputType.number,_88 onChanged: (value) async {_88 if (value.length != 6) return;_88_88 // kick off the verification process once 6 characters are entered_88 try {_88 final factorsResponse = await supabase.auth.mfa.listFactors();_88 final factor = factorsResponse.totp.first;_88 final factorId = factor.id;_88_88 final challenge =_88 await supabase.auth.mfa.challenge(factorId: factorId);_88 await supabase.auth.mfa.verify(_88 factorId: factorId,_88 challengeId: challenge.id,_88 code: value,_88 );_88 await supabase.auth.refreshSession();_88 if (mounted) {_88 context.go(HomePage.route);_88 }_88 } on AuthException catch (error) {_88 ScaffoldMessenger.of(context)_88 .showSnackBar(SnackBar(content: Text(error.message)));_88 } catch (error) {_88 ScaffoldMessenger.of(context).showSnackBar(_88 const SnackBar(content: Text('Unexpected error occurred')));_88 }_88 },_88 ),_88 ],_88 ),_88 );_88 }_88}
Step 5: Add a home page with secure contents
The home page is where the “secure” contents are displayed. We will create a dummy table with some dummy secure contents for demonstration purposes.
First, we create dummy content. Run the following SQL to create the table and add some content.
_14-- Dummy table that contains "secure" information_14create table_14 if not exists public.private_posts (_14 id int generated by default as identity primary key,_14 content text not null_14 );_14_14-- Dmmy "secure" data_14insert into_14 public.private_posts (content)_14values_14 ('Flutter is awesome!'),_14 ('Supabase is awesome!'),_14 ('Postgres is awesome!');
Now, we can add some row security policy to lock those data down so that only users who have signed in using MFA can view them.
Run the following SQL to secure our data from malicious users.
_10-- Enable RLS for private_posts table_10alter table_10 public.private_posts enable row level security;_10_10-- Create a policy that only allows read if they user has signed in via MFA_10create policy "Users can view private_posts if they have signed in via MFA" on public.private_posts for_10select_10 to authenticated using (auth.jwt () - > > 'aal' = 'aal2');
aal
here stands for Authenticator Assurance Level, and it will be aal1
for users who have only signed in with 1 sign-in method, and aal2
for users who have completed the MFA flow. Checking the aal
inside RLS policy ensures that the data cannot be viewed by users unless they complete the entire MFA flow.
The nice thing about RLS is that it gives us the flexibility to control how users can interact with the data. In this particular example, we are mandating MFA to view the data, but you could easily create layered permissions where for example a user can view the data with 1 factor, but can edit the data when signed in with MFA. You can see more examples in our official MFA guide here.
Now that we have the secure data in our Supabase instance, all we need to do is to display them in the HomePage. We can simply query the table and display it using a FutureBuilder
.
Create a lib/pages/home_page.dart
file and add the following.
_64import 'package:flutter/material.dart';_64import 'package:go_router/go_router.dart';_64import 'package:mfa_app/main.dart';_64import 'package:mfa_app/pages/auth/register_page.dart';_64import 'package:mfa_app/pages/list_mfa_page.dart';_64_64class HomePage extends StatelessWidget {_64 static const route = '/';_64_64 const HomePage({super.key});_64_64 @override_64 Widget build(BuildContext context) {_64 final privatePostsFuture =_64 supabase.from('private_posts').select<List<Map<String, dynamic>>>();_64_64 return Scaffold(_64 appBar: AppBar(_64 title: const Text('Home'),_64 actions: [_64 PopupMenuButton(_64 itemBuilder: (context) {_64 return [_64 PopupMenuItem(_64 child: const Text('Unenroll MFA'),_64 onTap: () {_64 context.push(ListMFAPage.route);_64 },_64 ),_64 PopupMenuItem(_64 child: const Text('Logout'),_64 onTap: () {_64 supabase.auth.signOut();_64 context.go(RegisterPage.route);_64 },_64 ),_64 ];_64 },_64 )_64 ],_64 ),_64 body: FutureBuilder<List<Map<String, dynamic>>>(_64 future: privatePostsFuture,_64 builder: (context, snapshot) {_64 if (snapshot.hasError) {_64 return Center(child: Text(snapshot.error.toString()));_64 }_64 if (!snapshot.hasData) {_64 return const Center(child: CircularProgressIndicator());_64 }_64_64 // Display the secure private content upon retrieval_64 final data = snapshot.data!;_64 return ListView.builder(_64 itemCount: data.length,_64 itemBuilder: (context, index) {_64 return ListTile(title: Text(data[index]['content']));_64 },_64 );_64 },_64 ),_64 );_64 }_64}
Because we have set the RLS policy, any user without going through the MFA flow will not see anything on this page.
One final page to add here is the unenrollment page. On this page, users can remove any factors that they have added. Once a user removes the factor, the user’s account will no longer be associated with the authenticator app, and they would have to go through the enrollment steps again.
Create lib/pages/list_mfa_page.dart
file and add the following.
_77import 'package:flutter/material.dart';_77import 'package:go_router/go_router.dart';_77import 'package:mfa_app/main.dart';_77import 'package:mfa_app/pages/auth/register_page.dart';_77_77/// A page that lists the currently signed in user's MFA methods._77///_77/// The user can unenroll the factors._77class ListMFAPage extends StatelessWidget {_77 static const route = '/list-mfa';_77 ListMFAPage({super.key});_77_77 final _factorListFuture = supabase.auth.mfa.listFactors();_77_77 @override_77 Widget build(BuildContext context) {_77 return Scaffold(_77 appBar: AppBar(title: const Text('List of MFA Factors')),_77 body: FutureBuilder(_77 future: _factorListFuture,_77 builder: (context, snapshot) {_77 if (snapshot.hasError) {_77 return Center(child: Text(snapshot.error.toString()));_77 }_77 if (!snapshot.hasData) {_77 return const Center(child: CircularProgressIndicator());_77 }_77_77 final response = snapshot.data!;_77 final factors = response.all;_77 return ListView.builder(_77 itemCount: factors.length,_77 itemBuilder: (context, index) {_77 final factor = factors[index];_77 return ListTile(_77 title: Text(factor.friendlyName ?? factor.factorType.name),_77 subtitle: Text(factor.status.name),_77 trailing: IconButton(_77 onPressed: () {_77 showDialog(_77 context: context,_77 builder: (context) {_77 return AlertDialog(_77 title: const Text(_77 'Are you sure you want to delete this factor? You will be signed out of the app upon removing the factor.',_77 ),_77 actions: [_77 TextButton(_77 onPressed: () {_77 context.pop();_77 },_77 child: const Text('cancel'),_77 ),_77 TextButton(_77 onPressed: () async {_77 await supabase.auth.mfa.unenroll(factor.id);_77 await supabase.auth.signOut();_77 if (context.mounted) {_77 context.go(RegisterPage.route);_77 }_77 },_77 child: const Text('delete'),_77 ),_77 ],_77 );_77 });_77 },_77 icon: const Icon(Icons.delete_outline),_77 ),_77 );_77 },_77 );_77 },_77 ),_77 );_77 }_77}
Step 6: Putting the pieces together with go_router
Now that we have all the pages, it’s time to put it all together with the help of go_router.
go_router, as you may know, is a routing package for Flutter, and its redirect feature is particularly helpful for implementing the complex requirement this app had. Particularly, we wanted to make sure that a user who has not yet set up MFA is redirected to the MFA setup page, and only users who have signed in land on the home page.
Another helpful feature of go_router comes when using deep links, and it automatically redirects the users to the correct path of the deep link. Because of this, we can ensure that user lands on the MFA setup page upon confirming their email address.
We will add the router in our lib/main.dart
file. Your main.dart
file should now look like this.
_102import 'package:flutter/material.dart';_102import 'package:go_router/go_router.dart';_102import 'package:mfa_app/pages/auth/login_page.dart';_102import 'package:mfa_app/pages/auth/register_page.dart';_102import 'package:mfa_app/pages/home_page.dart';_102import 'package:mfa_app/pages/list_mfa_page.dart';_102import 'package:mfa_app/pages/mfa/verify_page.dart';_102import 'package:supabase_flutter/supabase_flutter.dart';_102import 'package:mfa_app/pages/mfa/enroll_page.dart';_102_102void main() async {_102 await Supabase.initialize(_102 url: 'YOUR_SUPABASE_URL',_102 anonKey: 'YOUR_ANON_KEY',_102 );_102 runApp(const MyApp());_102}_102_102/// Extract SupabaseClient instance in a handy variable_102final supabase = Supabase.instance.client;_102_102final _router = GoRouter(_102 routes: [_102 GoRoute(_102 path: HomePage.route,_102 builder: (context, state) => const HomePage(),_102 ),_102 GoRoute(_102 path: ListMFAPage.route,_102 builder: (context, state) => ListMFAPage(),_102 ),_102 GoRoute(_102 path: LoginPage.route,_102 builder: (context, state) => const LoginPage(),_102 ),_102 GoRoute(_102 path: RegisterPage.route,_102 builder: (context, state) => const RegisterPage(),_102 ),_102 GoRoute(_102 path: MFAEnrollPage.route,_102 builder: (context, state) => const MFAEnrollPage(),_102 ),_102 GoRoute(_102 path: MFAVerifyPage.route,_102 builder: (context, state) => const MFAVerifyPage(),_102 ),_102 ],_102 redirect: (context, state) async {_102 // Any users can visit the /auth route_102 if (state.location.contains('/auth') == true) {_102 return null;_102 }_102_102 final session = supabase.auth.currentSession;_102 // A user without a session should be redirected to the register page_102 if (session == null) {_102 return RegisterPage.route;_102 }_102_102 final assuranceLevelData =_102 supabase.auth.mfa.getAuthenticatorAssuranceLevel();_102_102 // The user has not setup MFA yet, so send them to enroll MFA page._102 if (assuranceLevelData.currentLevel == AuthenticatorAssuranceLevels.aal1) {_102 await supabase.auth.refreshSession();_102 final nextLevel =_102 supabase.auth.mfa.getAuthenticatorAssuranceLevel().nextLevel;_102 if (nextLevel == AuthenticatorAssuranceLevels.aal2) {_102 // The user has already setup MFA, but haven't login via MFA_102 // Redirect them to the verify page_102 return MFAVerifyPage.route;_102 } else {_102 // The user has not yet setup MFA_102 // Redirect them to the enrollment page_102 return MFAEnrollPage.route;_102 }_102 }_102_102 // The user has signed invia MFA, and is allowed to view any page._102 return null;_102 },_102);_102_102class MyApp extends StatelessWidget {_102 const MyApp({super.key});_102_102 // This widget is the root of your application._102 @override_102 Widget build(BuildContext context) {_102 return MaterialApp.router(_102 title: 'MFA App',_102 debugShowCheckedModeBanner: false,_102 theme: ThemeData.light().copyWith(_102 inputDecorationTheme: const InputDecorationTheme(_102 border: OutlineInputBorder(),_102 ),_102 ),_102 routerConfig: _router,_102 );_102 }_102}
Conclusions and future iterations
We looked at how to incorporate Multi-Factor Authentication into a Flutter app with a complete enrollment and verification flow for new and existing users. We saw how we are able to control how the users can interact with the data using their MFA status.
Another common use case is to make MFA optional and allow the user to opt-in whenever they are ready. Optionally enrolling in MFA will require some tweaks in the code, but might be a fun one to try out.