Multi-Factor Authentication (MFA) adds an additional security layer by requiring users to provide a second form of verification. Supabase supports TOTP (Time-based One-Time Password) using authenticator apps.
Overview
Supabase MFA uses the TOTP standard, compatible with popular authenticator apps:
Google Authenticator
Microsoft Authenticator
Authy
1Password
Any TOTP-compatible app
How MFA Works
User Signs In
User authenticates with email/password or OAuth
Enroll MFA
User scans QR code or enters secret key into authenticator app
Verify Code
User enters 6-digit code from authenticator app
AAL2 Session
User now has an AAL2 (Authenticator Assurance Level 2) session
Assurance Levels
Supabase uses Authenticator Assurance Levels (AAL) to determine session security:
AAL1 : Single-factor authentication (email/password or OAuth)
AAL2 : Multi-factor authentication (MFA verified)
const { currentLevel , nextLevel } = supabase . auth . mfa . getAuthenticatorAssuranceLevel ()
console . log ( 'Current level:' , currentLevel ) // 'aal1' or 'aal2'
console . log ( 'Next level:' , nextLevel ) // 'aal2' if MFA is available
Enable MFA
Enable MFA in your Supabase dashboard:
Navigate to Authentication > Settings
Scroll to “Multi-Factor Authentication”
Toggle “Enable Multi-Factor Authentication”
Enroll MFA
Step 1: Initiate Enrollment
const { data , error } = await supabase . auth . mfa . enroll ({
factorType: 'totp' ,
})
if ( error ) {
console . error ( 'Error enrolling MFA:' , error . message )
return
}
const { id , type , totp } = data
// QR code for scanning
const qrCode = totp . qr_code // SVG data URL
// Secret for manual entry
const secret = totp . secret
// Factor ID for verification
const factorId = id
Step 2: Display QR Code
import { useState } from 'react'
import { supabase } from './lib/supabase'
export default function EnrollMFA () {
const [ qrCode , setQrCode ] = useState ( '' )
const [ secret , setSecret ] = useState ( '' )
const [ factorId , setFactorId ] = useState ( '' )
const [ code , setCode ] = useState ( '' )
const enrollMFA = async () => {
const { data , error } = await supabase . auth . mfa . enroll ({
factorType: 'totp' ,
})
if ( error ) {
alert ( error . message )
return
}
setQrCode ( data . totp . qr_code )
setSecret ( data . totp . secret )
setFactorId ( data . id )
}
const verifyMFA = async () => {
const challenge = await supabase . auth . mfa . challenge ({ factorId })
const { data , error } = await supabase . auth . mfa . verify ({
factorId ,
challengeId: challenge . data . id ,
code ,
})
if ( error ) {
alert ( 'Invalid code: ' + error . message )
} else {
alert ( 'MFA enrolled successfully!' )
}
}
return (
< div >
< h2 > Setup MFA </ h2 >
{ ! qrCode ? (
< button onClick = { enrollMFA } > Start MFA Enrollment </ button >
) : (
< div >
< p > Scan this QR code with your authenticator app: </ p >
< img src = { qrCode } alt = "QR Code" />
< p > Or enter this secret manually: </ p >
< code > { secret } </ code >
< input
type = "text"
placeholder = "Enter 6-digit code"
value = { code }
onChange = { ( e ) => setCode ( e . target . value ) }
maxLength = { 6 }
/>
< button onClick = { verifyMFA } > Verify and Enable MFA </ button >
</ div >
) }
</ div >
)
}
Step 3: Verify Enrollment
// Create a challenge
const { data : challengeData , error : challengeError } =
await supabase . auth . mfa . challenge ({ factorId })
const challengeId = challengeData . id
// Verify the code from authenticator app
const { data , error } = await supabase . auth . mfa . verify ({
factorId ,
challengeId ,
code: '123456' , // 6-digit code from authenticator
})
if ( error ) {
console . error ( 'Verification failed:' , error . message )
} else {
console . log ( 'MFA enrolled successfully!' )
}
Complete Flutter MFA Example
import 'package:flutter/material.dart' ;
import 'package:flutter_svg/flutter_svg.dart' ;
import 'package:supabase_flutter/supabase_flutter.dart' ;
class MFAEnrollPage extends StatefulWidget {
const MFAEnrollPage ({ super .key});
@override
State < MFAEnrollPage > createState () => _MFAEnrollPageState ();
}
class _MFAEnrollPageState extends State < MFAEnrollPage > {
final supabase = Supabase .instance.client;
late final Future < MFAEnrollResponse > _enrollFuture;
@override
void initState () {
super . initState ();
_enrollFuture = supabase.auth.mfa. enroll ();
}
@override
Widget build ( BuildContext context) {
return Scaffold (
appBar : AppBar (title : const Text ( 'Setup MFA' )),
body : FutureBuilder (
future : _enrollFuture,
builder : (context, snapshot) {
if ( ! snapshot.hasData) {
return const Center (child : CircularProgressIndicator ());
}
final response = snapshot.data ! ;
final qrCodeUrl = response.totp.qrCode;
final secret = response.totp.secret;
final factorId = response.id;
return ListView (
padding : const EdgeInsets . all ( 20 ),
children : [
const Text (
'Scan QR code with your authenticator app:' ,
style : TextStyle (fontWeight : FontWeight .bold),
),
const SizedBox (height : 16 ),
SvgPicture . string (qrCodeUrl, width : 200 , height : 200 ),
const SizedBox (height : 16 ),
Text ( 'Or enter this secret: $ secret ' ),
const SizedBox (height : 24 ),
TextFormField (
decoration : const InputDecoration (
hintText : '000000' ,
labelText : 'Enter 6-digit code' ,
),
keyboardType : TextInputType .number,
maxLength : 6 ,
onChanged : (value) async {
if (value.length != 6 ) return ;
try {
final challenge = await supabase.auth.mfa
. challenge (factorId : factorId);
await supabase.auth.mfa. verify (
factorId : factorId,
challengeId : challenge.id,
code : value,
);
ScaffoldMessenger . of (context). showSnackBar (
const SnackBar (content : Text ( 'MFA enabled!' )),
);
} catch (error) {
ScaffoldMessenger . of (context). showSnackBar (
SnackBar (content : Text ( 'Error: $ error ' )),
);
}
},
),
],
);
},
),
);
}
}
Challenge and Verify
After enrollment, users must verify their MFA code at each login:
// Step 1: User signs in with password
const { data : signInData , error : signInError } =
await supabase . auth . signInWithPassword ({
email: 'user@example.com' ,
password: 'password' ,
})
if ( signInError ) {
console . error ( 'Sign in error:' , signInError )
return
}
// Step 2: Check if MFA is required
const { currentLevel , nextLevel } =
supabase . auth . mfa . getAuthenticatorAssuranceLevel ()
if ( nextLevel === 'aal2' ) {
// Step 3: Get user's enrolled factors
const { data : factors } = await supabase . auth . mfa . listFactors ()
const totpFactor = factors . totp [ 0 ]
// Step 4: Create challenge
const { data : challengeData } = await supabase . auth . mfa . challenge ({
factorId: totpFactor . id ,
})
// Step 5: Prompt user for code and verify
const code = prompt ( 'Enter your 6-digit MFA code:' )
const { error : verifyError } = await supabase . auth . mfa . verify ({
factorId: totpFactor . id ,
challengeId: challengeData . id ,
code ,
})
if ( verifyError ) {
console . error ( 'Invalid MFA code' )
} else {
console . log ( 'MFA verification successful!' )
}
}
List Enrolled Factors
const { data , error } = await supabase . auth . mfa . listFactors ()
if ( data ) {
console . log ( 'TOTP factors:' , data . totp )
console . log ( 'All factors:' , data . all )
data . totp . forEach ( factor => {
console . log ( 'Factor ID:' , factor . id )
console . log ( 'Status:' , factor . status )
console . log ( 'Created:' , factor . created_at )
})
}
Unenroll MFA
Remove MFA from a user’s account:
const { data , error } = await supabase . auth . mfa . unenroll ({
factorId: 'factor-id-here' ,
})
if ( error ) {
console . error ( 'Error unenrolling:' , error . message )
} else {
console . log ( 'MFA removed successfully' )
}
Enforce MFA with RLS
Require AAL2 for sensitive operations:
create policy "Require MFA for sensitive data"
on sensitive_table
for all
using (
auth . jwt () ->> 'aal' = 'aal2'
);
MFA in Mobile Apps
Flutter Complete Flow
import 'package:supabase_flutter/supabase_flutter.dart' ;
final supabase = Supabase .instance.client;
// Check AAL after sign in
final assuranceLevel = supabase.auth.mfa. getAuthenticatorAssuranceLevel ();
if (assuranceLevel.currentLevel == AuthenticatorAssuranceLevels .aal1) {
// Refresh to check if user has enrolled MFA
await supabase.auth. refreshSession ();
final nextLevel = supabase.auth.mfa
. getAuthenticatorAssuranceLevel ().nextLevel;
if (nextLevel == AuthenticatorAssuranceLevels .aal2) {
// User has MFA enrolled, prompt for verification
navigateToMFAVerifyPage ();
} else {
// User hasn't enrolled MFA yet
navigateToMFAEnrollPage ();
}
}
Error Handling
Common MFA errors:
Error Description Solution mfa_enrollment_not_foundFactor ID not found Re-enroll MFA invalid_codeWrong TOTP code Check time sync on device mfa_challenge_expiredChallenge expired Create new challenge max_enrolled_factorsToo many factors Unenroll unused factors
Time Synchronization
TOTP codes are time-sensitive. Ensure the user’s device clock is synchronized with network time for codes to work correctly.
Recovery Codes
Supabase doesn’t currently support recovery codes. Implement your own recovery mechanism or allow users to reset MFA via email verification.
Testing MFA
For testing, you can use:
# Generate TOTP code from secret (for testing)
oathtool --totp --base32 YOUR_SECRET_HERE
Best Practices
Optional Enrollment Make MFA optional initially, encourage adoption over time
Clear Instructions Provide step-by-step instructions with screenshots
Backup Methods Offer email-based recovery for locked accounts
Progressive Security Require MFA only for sensitive operations
Next Steps
Row Level Security Learn how to protect data with RLS policies
Storage Security Secure file uploads and downloads