Multi-Factor Authentication (MFA) adds a crucial layer of security to user accounts by requiring more than one method of verification to prove a user's identity. In an era of increasing cyber threats, implementing MFA is no longer a luxury but a fundamental necessity for protecting sensitive data and maintaining user trust. This article explores the implementation of MFA using Flutter for the client-side and Firebase Authentication as the backend service, focusing specifically on phone SMS as a second factor.
Flutter & Firebase Auth: Multi-Factor Authentication Implementation
Introduction to Multi-Factor Authentication
MFA significantly enhances the security posture of an application by making it harder for unauthorized users to gain access, even if they manage to compromise one authentication factor (e.g., a password). It typically combines something the user knows (password), something the user has (phone, hardware token), or something the user is (biometrics).
Firebase Authentication & MFA Capabilities
Firebase Authentication provides robust support for MFA, primarily using phone SMS as a second factor. When a user with MFA enabled attempts to sign in, Firebase will challenge them to provide verification from their registered second factor. Firebase's MFA implementation integrates seamlessly with its existing authentication methods, allowing for a flexible and secure user experience.
Before proceeding with the Flutter implementation, ensure MFA is enabled for your Firebase project in the Firebase Console under "Authentication" > "Settings" > "Multi-factor authentication". You will need to enable at least "Phone SMS" as a second factor.
Implementing MFA with Flutter & Firebase Auth
1. Project Setup
Ensure your Flutter project is configured with Firebase. Add the necessary dependencies to your pubspec.yaml:
dependencies:
flutter:
sdk: flutter
firebase_core: ^latest_version
firebase_auth: ^latest_version
Initialize Firebase in your main.dart:
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
runApp(MyApp());
}
2. Enrolling an MFA Factor (Adding a Phone Number)
To enroll a user in MFA, they must first be signed in with a primary authentication method (e.g., email/password). Once signed in, you can initiate the process to add a phone number as a second factor.
import 'package:firebase_auth/firebase_auth.dart';
final FirebaseAuth _auth = FirebaseAuth.instance;
Future<void> enrollMfaWithPhoneNumber(String phoneNumber) async {
User? user = _auth.currentUser;
if (user == null) {
print("No user is signed in.");
return;
}
try {
// 1. Send verification code to the phone number
await _auth.verifyPhoneNumber(
phoneNumber: phoneNumber,
verificationCompleted: (PhoneAuthCredential credential) async {
// Auto-retrieval or instant verification
// This is usually for Android and handled internally
},
verificationFailed: (FirebaseAuthException e) {
print("Phone number verification failed: ${e.message}");
// Handle error: invalid phone number, too many requests, etc.
},
codeSent: (String verificationId, int? resendToken) async {
// Prompt user to enter the SMS code
// For demonstration, we'll assume the code is received and entered
print("Code sent to $phoneNumber. Verification ID: $verificationId");
// After the user enters the code:
// String smsCode = await _promptForSmsCode(); // Implement this
// PhoneAuthCredential credential = PhoneAuthProvider.credential(
// verificationId: verificationId,
// smsCode: smsCode,
// );
// For this example, let's assume `smsCode` is obtained and `credential` is formed
// In a real app, `smsCode` would come from user input.
// For simplicity, we'll create a dummy credential for demonstration of enrollment step.
// REAL IMPLEMENTATION REQUIRES USER INPUT FOR `smsCode`.
String smsCode = "123456"; // REPLACE WITH ACTUAL USER INPUT
PhoneAuthCredential credential = PhoneAuthProvider.credential(
verificationId: verificationId,
smsCode: smsCode,
);
// 2. Create a PhoneMultiFactorAssertion from the credential
PhoneMultiFactorAssertion assertion = PhoneMultiFactorAssertion.fromPhoneAuthCredential(credential);
// 3. Enroll the user with the new MFA factor
await user.multiFactor.enroll(assertion);
print("MFA enrolled successfully for user: ${user.uid}");
},
codeAutoRetrievalTimeout: (String verificationId) {
print("SMS code auto-retrieval timed out for $verificationId");
},
);
} on FirebaseAuthException catch (e) {
print("Error during MFA enrollment: ${e.message}");
// Handle specific errors like 'auth/invalid-phone-number'
} catch (e) {
print("An unexpected error occurred during MFA enrollment: $e");
}
}
// Example usage:
// If a user is signed in:
// enrollMfaWithPhoneNumber("+15551234567");
3. Signing In with MFA
When a user with MFA enabled attempts to sign in with their primary factor, Firebase will return a FirebaseAuthMfaException. You must then use the information from this exception to prompt the user for their second factor.
import 'package:firebase_auth/firebase_auth.dart';
final FirebaseAuth _auth = FirebaseAuth.instance;
Future<User?> signInWithEmailAndPasswordAndMfa(String email, String password) async {
try {
UserCredential userCredential = await _auth.signInWithEmailAndPassword(
email: email,
password: password,
);
print("Primary sign-in successful: ${userCredential.user?.uid}");
return userCredential.user;
} on FirebaseAuthMfaException catch (e) {
print("MFA is required for this account.");
MfaResolver resolver = e.resolver;
// Handle different MFA factors. For Phone SMS:
if (resolver.hints.isNotEmpty && resolver.hints.first.factorType == MultiFactorFactorType.phone) {
MultiFactorEnrollment phoneMfaHint = resolver.hints.first;
String phoneNumber = phoneMfaHint.phoneInfo!;
try {
// Send SMS verification code to the registered phone number
await _auth.verifyPhoneNumber(
multiFactorSession: resolver.session,
phoneNumber: phoneNumber,
verificationCompleted: (PhoneAuthCredential credential) {
// Auto-retrieval
},
verificationFailed: (FirebaseAuthException e) {
print("MFA phone verification failed: ${e.message}");
// Handle error
},
codeSent: (String verificationId, int? resendToken) async {
print("MFA code sent to $phoneNumber. Verification ID: $verificationId");
// Prompt user for SMS code
// String smsCode = await _promptForSmsCode(); // Implement this
String smsCode = "123456"; // REPLACE WITH ACTUAL USER INPUT
PhoneAuthCredential credential = PhoneAuthProvider.credential(
verificationId: verificationId,
smsCode: smsCode,
);
// Create a PhoneMultiFactorAssertion for the sign-in resolution
PhoneMultiFactorAssertion assertion = PhoneMultiFactorAssertion.fromPhoneAuthCredential(credential);
// Resolve the MFA sign-in challenge
UserCredential userCredential = await resolver.resolveSignIn(assertion);
print("MFA sign-in successful: ${userCredential.user?.uid}");
return userCredential.user;
},
codeAutoRetrievalTimeout: (String verificationId) {
print("MFA SMS code auto-retrieval timed out for $verificationId");
},
);
} on FirebaseAuthException catch (e) {
print("Error during MFA sign-in challenge: ${e.message}");
}
} else {
print("Unsupported MFA factor type.");
}
} on FirebaseAuthException catch (e) {
print("Primary sign-in failed: ${e.message}");
// Handle other sign-in errors like 'auth/user-not-found', 'auth/wrong-password'
} catch (e) {
print("An unexpected error occurred during sign-in: $e");
}
return null;
}
// Example usage:
// User? user = await signInWithEmailAndPasswordAndMfa("[email protected]", "password123");
// if (user != null) {
// print("Signed in user: ${user.uid}");
// }
4. Managing MFA Factors
Users might need to view or unlink their registered MFA factors.
Listing MFA Factors
You can retrieve a list of all enrolled MFA factors for the currently signed-in user:
Future<void> listMfaFactors() async {
User? user = _auth.currentUser;
if (user == null) {
print("No user is signed in.");
return;
}
try {
List<MultiFactorInfo> factors = user.multiFactor.enrolledFactors;
if (factors.isEmpty) {
print("No MFA factors enrolled for this user.");
return;
}
print("MFA Factors for user ${user.uid}:");
for (var factor in factors) {
print(" Factor ID: ${factor.factorId}");
print(" Display Name: ${factor.displayName ?? 'N/A'}");
print(" Enrollment Time: ${factor.enrollmentTime}");
if (factor is PhoneMultiFactorInfo) {
print(" Phone Number: ${factor.phoneNumber}");
}
}
} catch (e) {
print("Error listing MFA factors: $e");
}
}
Unlinking an MFA Factor
Users can also unlink an MFA factor if they no longer wish to use it.
Future<void> unlinkMfaFactor(String factorUidToUnlink) async {
User? user = _auth.currentUser;
if (user == null) {
print("No user is signed in.");
return;
}
try {
// Re-authenticate the user before unlinking for security reasons
// This example uses `reauthenticateWithProvider` with current credentials,
// but often a fresh sign-in is required.
AuthCredential credential = EmailAuthProvider.credential(
email: user.email!,
password: "current_password", // Replace with actual password input
);
await user.reauthenticateWithCredential(credential);
// Find the factor by UID and unlink
List<MultiFactorInfo> factors = user.multiFactor.enrolledFactors;
MultiFactorInfo? factorToUnlink;
for (var factor in factors) {
if (factor.uid == factorUidToUnlink) {
factorToUnlink = factor;
break;
}
}
if (factorToUnlink != null) {
await user.multiFactor.unenroll(factorUid: factorToUnlink.uid);
print("MFA factor with UID $factorUidToUnlink unlinked successfully.");
} else {
print("Factor with UID $factorUidToUnlink not found.");
}
} on FirebaseAuthException catch (e) {
print("Error unlinking MFA factor: ${e.message}");
// Handle errors like 'auth/requires-recent-login' for reauthentication
} catch (e) {
print("An unexpected error occurred during unlinking MFA factor: $e");
}
}
Conclusion
Implementing Multi-Factor Authentication with Flutter and Firebase Auth significantly elevates the security posture of your application. While the process involves several steps—from enrolling factors to handling MFA challenges during sign-in—Firebase provides a robust and relatively straightforward API to integrate these critical security features. By following these guidelines, developers can provide a more secure and trustworthy experience for their users.