Hardening facial recognition for production security requires four additions on top of basic face matching: liveness detection (blink or head-turn challenge to defeat printed photos), rate limiting (3 failed attempts triggers a 30-minute Firestore-backed lockout), multi-factor requirement (face can never be the sole authentication factor), and a complete audit trail logging every attempt with device metadata to Firestore.
Security Hardening for Face Recognition Authentication
Basic face recognition matching (covered in the enrollment and authentication tutorial) has known vulnerabilities: printed photos, phone screen photos, and even video playback can fool naive matching systems. This tutorial adds four security layers that address these attack vectors. Each layer is independent and can be adopted separately, but together they meet the security bar required for apps handling sensitive data, financial transactions, or regulated information.
Prerequisites
- Completed the 'How to Implement Face Recognition for User Authentication in FlutterFlow' tutorial
- Firebase Firestore with users collection and faceEmbedding field already set up
- Firebase Cloud Functions deployed for face embedding extraction
- Understanding of FlutterFlow Custom Actions and Action Flows
Step-by-step guide
Add Liveness Challenge Screen Before Face Capture
Add Liveness Challenge Screen Before Face Capture
Liveness detection requires the user to perform a specific action — blinking, turning their head, or smiling — to prove a live person is present rather than a photo. In FlutterFlow, create a new page called 'LivenessChallenge'. It shows the front camera feed (via a Custom Widget using camera package) and displays a text instruction like 'Please blink twice' or 'Turn your head slowly to the right'. Each time the page loads, randomly select one challenge from three types stored in an App State list. After the user completes the challenge, navigate to the main face capture. You can validate the challenge with basic ML Kit pose/landmark detection — if the detected face landmarks show the expected movement (eyes closed, head rotation angle changed by 15+ degrees), the liveness check passes. Wire the Liveness Challenge page as the required step before the authenticateWithFace Custom Action.
1// Custom Action: performLivenessCheck2// Return type: bool3// Packages: camera, google_ml_kit (face detection)45Future<bool> performLivenessCheck(String challengeType) async {6 // challengeType: 'blink' | 'headTurnLeft' | 'headTurnRight' | 'smile'7 final cameras = await availableCameras();8 final frontCamera = cameras.firstWhere(9 (c) => c.lensDirection == CameraLensDirection.front,10 orElse: () => cameras.first,11 );1213 final controller = CameraController(14 frontCamera,15 ResolutionPreset.medium,16 enableAudio: false,17 );18 await controller.initialize();1920 bool challengePassed = false;21 int frameCount = 0;22 const maxFrames = 90; // 3 seconds at 30fps2324 final faceDetector = FaceDetector(25 options: FaceDetectorOptions(26 enableClassification: true, // for blink/smile27 enableContours: true,28 enableLandmarks: true,29 enableTracking: true,30 performanceMode: FaceDetectorMode.accurate,31 ),32 );3334 await controller.startImageStream((CameraImage image) async {35 if (frameCount++ > maxFrames || challengePassed) return;3637 // Convert to InputImage and detect faces38 // (Platform-specific byte conversion omitted for brevity)39 // final faces = await faceDetector.processImage(inputImage);40 // if (faces.isEmpty) return;41 // final face = faces.first;4243 // Validate challenge based on type44 // if (challengeType == 'blink') {45 // challengePassed =46 // (face.leftEyeOpenProbability ?? 1.0) < 0.2 &&47 // (face.rightEyeOpenProbability ?? 1.0) < 0.2;48 // } else if (challengeType == 'smile') {49 // challengePassed = (face.smilingProbability ?? 0.0) > 0.8;50 // } else if (challengeType == 'headTurnLeft') {51 // challengePassed = (face.headEulerAngleY ?? 0.0) < -20;52 // } else if (challengeType == 'headTurnRight') {53 // challengePassed = (face.headEulerAngleY ?? 0.0) > 20;54 // }55 });5657 await Future.delayed(const Duration(seconds: 3));58 await controller.stopImageStream();59 await controller.dispose();60 await faceDetector.close();6162 return challengePassed;63}Expected result: The liveness challenge page appears before face capture. Submitting a static photo of a face fails the challenge. A live user completing the requested action passes.
Implement Server-Side Lockout in Firestore
Implement Server-Side Lockout in Firestore
Store lockout state in Firestore, not App State — App State resets when the app is force-closed. In your Firestore users collection, add two fields: 'faceAuthFailedAttempts' (Number, default 0) and 'faceAuthLockedUntil' (Timestamp, nullable). In the authentication Custom Action, before attempting face matching, read these fields from the user's document. If lockedUntil is set and is in the future, return immediately with an error message showing remaining lockout time. If the face match fails, increment faceAuthFailedAttempts using Firestore's FieldValue.increment(1). If the new count reaches 3, set faceAuthLockedUntil to Timestamp.fromDate(DateTime.now().add(Duration(minutes: 30))) and reset faceAuthFailedAttempts to 0. On success, reset both fields to their defaults.
1// Custom Action: authenticateWithFaceSecure2// Return type: String (success, locked, failed, error)3// Packages: cloud_firestore, firebase_auth, image_picker45Future<String> authenticateWithFaceSecure() async {6 final uid = FirebaseAuth.instance.currentUser?.uid;7 if (uid == null) return 'error:not_authenticated';89 // 1. Check lockout status server-side10 final userDoc = await FirebaseFirestore.instance11 .collection('users').doc(uid).get();12 final data = userDoc.data()!;1314 final lockedUntilTs = data['faceAuthLockedUntil'] as Timestamp?;15 if (lockedUntilTs != null) {16 final lockedUntil = lockedUntilTs.toDate();17 if (DateTime.now().isBefore(lockedUntil)) {18 final remaining = lockedUntil.difference(DateTime.now());19 final minutes = remaining.inMinutes + 1;20 return 'locked:Face authentication locked for $minutes more minutes';21 } else {22 // Lockout expired — clear it23 await FirebaseFirestore.instance24 .collection('users').doc(uid).update({25 'faceAuthLockedUntil': FieldValue.delete(),26 'faceAuthFailedAttempts': 0,27 });28 }29 }3031 // 2. Capture photo and extract embedding32 final picker = ImagePicker();33 final photo = await picker.pickImage(34 source: ImageSource.camera,35 preferredCameraDevice: CameraDevice.front,36 imageQuality: 85,37 );38 if (photo == null) return 'cancelled';3940 final bytes = await photo.readAsBytes();41 final base64Image = base64Encode(bytes);4243 try {44 final callable = FirebaseFunctions.instance45 .httpsCallable('extractFaceEmbedding');46 final result = await callable.call({'imageBase64': base64Image});47 final liveEmbedding = List<double>.from(48 (result.data['embedding'] as List).map((e) => (e as num).toDouble()),49 );50 final storedEmbedding = List<double>.from(51 (data['faceEmbedding'] as List).map((e) => (e as num).toDouble()),52 );5354 final similarity = _cosineSimilarity(liveEmbedding, storedEmbedding);5556 if (similarity >= 0.85) {57 // Success — reset failure count58 await FirebaseFirestore.instance59 .collection('users').doc(uid).update({60 'faceAuthFailedAttempts': 0,61 'faceAuthLockedUntil': FieldValue.delete(),62 });63 await _logFaceAuthEvent(uid, 'success', similarity);64 return 'success';65 } else {66 // Failure — increment count, check lockout threshold67 final newCount =68 ((data['faceAuthFailedAttempts'] as num?)?.toInt() ?? 0) + 1;69 final updates = <String, dynamic>{70 'faceAuthFailedAttempts': newCount,71 };72 if (newCount >= 3) {73 updates['faceAuthLockedUntil'] = Timestamp.fromDate(74 DateTime.now().add(const Duration(minutes: 30)),75 );76 updates['faceAuthFailedAttempts'] = 0;77 }78 await FirebaseFirestore.instance79 .collection('users').doc(uid).update(updates);80 await _logFaceAuthEvent(uid, 'failed', similarity);81 return newCount >= 382 ? 'locked:Too many failed attempts. Locked for 30 minutes.'83 : 'failed:Face not recognized. ${3 - newCount} attempt(s) remaining.';84 }85 } on FirebaseFunctionsException catch (e) {86 await _logFaceAuthEvent(uid, 'error', 0);87 return 'error:${e.message}';88 }89}Expected result: After 3 failed face authentication attempts, the Firestore document shows faceAuthLockedUntil set 30 minutes in the future. The action returns a 'locked' response on subsequent calls until the time expires.
Enforce Face as Second Factor Only
Enforce Face as Second Factor Only
Configure your authentication flow to require email/password login FIRST, and only then present the face authentication challenge. In FlutterFlow's Action Flow on your login button: (1) Action 1: Log In (Firebase Auth with email and password) — on success, check if user has faceEnrolled=true. (2) If faceEnrolled is true, set a Page State variable 'requiresFaceAuth' to true and navigate to a 'Face Verification' interstitial page instead of the home page. (3) On the Face Verification page, run the authenticateWithFaceSecure Custom Action. Only navigate to Home on 'success'. Add a 'Skip face verification' option that sends a re-verification email and temporarily disables face auth for the current session — this is the recovery path for users who cannot pass face auth (new glasses, injury, etc.).
Expected result: Users must complete email/password login before face authentication is even presented. Bypassing the face auth page directly to home is impossible without a valid session from step one.
Build the Firestore Audit Trail
Build the Firestore Audit Trail
Create a 'face_auth_events' Firestore collection. Each document records: 'userId' (String), 'eventType' (String: 'success', 'failed', 'locked', 'enrolled', 'unenrolled'), 'similarity' (Number, the cosine similarity score), 'timestamp' (Timestamp), 'deviceModel' (String, from device_info_plus package), 'ipAddress' (String, from Cloud Function), and 'sessionId' (String, a UUID generated per login attempt). The _logFaceAuthEvent helper in the previous step writes to this collection. Build a simple 'Security Events' page in FlutterFlow (Admin only, protected by role-based access control) that shows this collection in a Repeating Group with date filtering. This gives you visibility into suspicious patterns like many failed attempts from multiple devices.
1// Helper: _logFaceAuthEvent (used inside Custom Actions)2// Call this after every face authentication outcome34Future<void> _logFaceAuthEvent(5 String uid,6 String eventType,7 double similarity,8) async {9 String deviceModel = 'unknown';10 try {11 final info = DeviceInfoPlugin();12 if (Platform.isAndroid) {13 final android = await info.androidInfo;14 deviceModel = '${android.manufacturer} ${android.model}';15 } else if (Platform.isIOS) {16 final ios = await info.iosInfo;17 deviceModel = ios.model;18 }19 } catch (_) {}2021 await FirebaseFirestore.instance22 .collection('face_auth_events')23 .add({24 'userId': uid,25 'eventType': eventType,26 'similarity': similarity,27 'deviceModel': deviceModel,28 'timestamp': FieldValue.serverTimestamp(),29 'sessionId': DateTime.now().millisecondsSinceEpoch.toString(),30 });31}Expected result: Every face authentication attempt creates a document in face_auth_events with the outcome, similarity score, and device model. Admin security page shows a timeline of all events.
Complete working example
1// ============================================================2// FlutterFlow Face Recognition — Security Hardening3// ============================================================4// Cosine similarity helper (used by auth actions)5double _cosineSimilarity(List<double> a, List<double> b) {6 double dot = 0, normA = 0, normB = 0;7 for (int i = 0; i < a.length; i++) {8 dot += a[i] * b[i];9 normA += a[i] * a[i];10 normB += b[i] * b[i];11 }12 if (normA == 0 || normB == 0) return 0.0;13 return dot / (sqrt(normA) * sqrt(normB));14}1516// Audit logging helper17Future<void> _logFaceAuthEvent(18 String uid, String eventType, double similarity) async {19 String deviceModel = 'unknown';20 try {21 if (Platform.isAndroid) {22 final info = await DeviceInfoPlugin().androidInfo;23 deviceModel = '${info.manufacturer} ${info.model}';24 } else if (Platform.isIOS) {25 final info = await DeviceInfoPlugin().iosInfo;26 deviceModel = info.model;27 }28 } catch (_) {}29 await FirebaseFirestore.instance.collection('face_auth_events').add({30 'userId': uid,31 'eventType': eventType,32 'similarity': similarity,33 'deviceModel': deviceModel,34 'timestamp': FieldValue.serverTimestamp(),35 });36}3738// Lockout check helper — returns null if not locked, or lock message39Future<String?> _checkLockout(String uid) async {40 final doc = await FirebaseFirestore.instance41 .collection('users').doc(uid).get();42 final ts = doc.data()?['faceAuthLockedUntil'] as Timestamp?;43 if (ts == null) return null;44 final until = ts.toDate();45 if (DateTime.now().isBefore(until)) {46 final remaining = until.difference(DateTime.now()).inMinutes + 1;47 return 'Account locked for $remaining more minute(s). Try again later.';48 }49 // Lockout expired — clean up50 await FirebaseFirestore.instance.collection('users').doc(uid).update({51 'faceAuthLockedUntil': FieldValue.delete(),52 'faceAuthFailedAttempts': 0,53 });54 return null;55}5657// Record a failure and apply lockout if threshold reached58Future<void> _recordFailure(String uid) async {59 final ref = FirebaseFirestore.instance.collection('users').doc(uid);60 final doc = await ref.get();61 final currentCount =62 ((doc.data()?['faceAuthFailedAttempts'] as num?)?.toInt() ?? 0) + 1;63 final updates = <String, dynamic>{64 'faceAuthFailedAttempts': currentCount65 };66 if (currentCount >= 3) {67 updates['faceAuthLockedUntil'] = Timestamp.fromDate(68 DateTime.now().add(const Duration(minutes: 30)),69 );70 updates['faceAuthFailedAttempts'] = 0;71 }72 await ref.update(updates);73}Common mistakes
Why it's a problem: Using face recognition as the ONLY authentication factor
How to avoid: Always require face recognition as a SECOND factor alongside something the user knows (password) or has (OTP code). The face factor supplements security — it never replaces the first factor. Always provide an account recovery path that does not rely solely on face auth.
Why it's a problem: Storing lockout state only in App State instead of Firestore
How to avoid: Write lockout state (failedAttempts count and lockedUntil timestamp) to the user's Firestore document. Read and enforce lockout from Firestore at the START of every authentication attempt, before any face capture occurs.
Why it's a problem: Not providing a bypass path for users who cannot complete face authentication
How to avoid: Always provide an alternative authentication path: 'Trouble with face recognition? Verify your identity by email.' This sends a one-time code to the registered email and grants session access after verification, while flagging the account for re-enrollment.
Why it's a problem: Skipping audit logging to save Firestore write costs
How to avoid: Always log every authentication event (success and failure) with timestamp, deviceModel, and outcome. Firestore write costs for an audit log are minimal — even 10,000 auth events per day costs about $0.10 in Firestore writes.
Why it's a problem: Displaying the cosine similarity score in a debug label visible to end users
How to avoid: Log similarity scores to the server-side audit trail only. Show users only pass/fail outcomes. Remove any debug Text widgets showing similarity values before production deployment.
Best practices
- Implement all four security layers together — liveness, lockout, multi-factor, and audit — for a defense-in-depth approach rather than relying on any single measure.
- Choose liveness challenges randomly from a set of 3-4 options so attackers cannot pre-record a specific response to replay.
- Set lockout duration to 30 minutes for 3 failures — short enough to not frustrate legitimate users, long enough to prevent automated attacks.
- Send a security email notification to the user's registered email when a lockout is triggered — this alerts legitimate users to suspicious access attempts.
- Review audit logs weekly using a Cloud Function or Firestore scheduled query — look for accounts with unusually high failure rates indicating targeted attacks.
- Re-prompt for face enrollment if the last enrollment was over 180 days ago — face appearance changes significantly over time and stale embeddings cause false rejections.
- Consult RapidDev for production deployments handling sensitive financial or medical data — biometric authentication systems require security reviews and may have regulatory requirements beyond what this tutorial covers.
- Never log the actual face embedding or image to the audit trail — embeddings are biometric data subject to strict privacy regulations in most jurisdictions.
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I'm building a production face recognition authentication system in FlutterFlow backed by Firebase. I need to add: (1) server-side lockout after 3 failed attempts stored in Firestore, (2) audit trail logging every attempt with outcome and device info, and (3) enforcement that face auth is always second-factor only after password login. Write the Dart Custom Action code for the secure authentication flow including lockout check, failure recording, and audit logging.
Write a FlutterFlow Custom Action in Dart called performLivenessCheck that takes a challengeType parameter ('blink', 'smile', 'headTurnLeft', 'headTurnRight'), uses the google_ml_kit face detection package to analyze a 3-second camera stream, and returns true if the face performs the requested challenge during the observation window.
Frequently asked questions
Can liveness detection completely prevent spoofing attacks?
No single liveness technique is completely foolproof. Blink and head-turn detection defends against static photos. It is less effective against high-quality video replays or 3D face models. For the highest security, combine multiple liveness challenges, use 3D depth sensing if the device supports it (iPhone Face ID hardware), and implement rate limiting to prevent automated attack attempts.
What GDPR requirements apply to storing face authentication data?
Under GDPR, face images and face embeddings are 'biometric data' — a special category requiring explicit informed consent, a specific legal basis (typically consent or legitimate interest), data minimization (store embeddings, not images), the right to erasure on request, and a Data Protection Impact Assessment (DPIA) for high-risk processing. Consult a legal professional before deploying biometric authentication in EU jurisdictions.
How does the lockout prevent attackers if they can just create a new account?
The lockout is per-account, targeting attackers who have the victim's email/password (first factor) and are trying to bypass face authentication (second factor). Creating a new account would not help because they would need the victim's account. The lockout is defense against the specific attack vector of an adversary who has stolen first-factor credentials.
Should I notify users by push notification when face auth is locked?
Yes — sending an email (not just push notification, since the device may have been stolen) when an account lockout triggers alerts legitimate users that someone is attempting unauthorized access. Include the device model from the audit log and instructions to change their password if they did not initiate the attempts.
What happens to the audit trail when a user deletes their account?
Under GDPR's right to erasure, you should delete face_auth_events documents linked to the user's ID when they delete their account. A Cloud Function triggered by the Firestore user document deletion can cascade-delete all associated audit records. However, you may retain anonymized aggregate statistics for fraud detection purposes.
Is a 0.85 cosine similarity threshold appropriate for all user populations?
The appropriate threshold depends on your face embedding model's discriminative power. For Cloud Vision landmark-based embeddings (used in this tutorial series), 0.85 is a reasonable starting point. For better-performing dedicated models like FaceNet, a Euclidean distance threshold under 1.0 is typical. Tune the threshold by testing with a diverse set of users and measuring false acceptance rate (FAR) and false rejection rate (FRR).
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation