# How to Implement Facial Recognition for Enhanced Security in FlutterFlow

- Tool: FlutterFlow
- Difficulty: Intermediate
- Time required: 45-60 min
- Compatibility: FlutterFlow Pro+ (Cloud Functions, Firestore, and custom Dart required)
- Last updated: March 2026

## TL;DR

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.

## Before you start

- 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

### 1. 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.

```
// Custom Action: performLivenessCheck
// Return type: bool
// Packages: camera, google_ml_kit (face detection)

Future<bool> performLivenessCheck(String challengeType) async {
  // challengeType: 'blink' | 'headTurnLeft' | 'headTurnRight' | 'smile'
  final cameras = await availableCameras();
  final frontCamera = cameras.firstWhere(
    (c) => c.lensDirection == CameraLensDirection.front,
    orElse: () => cameras.first,
  );

  final controller = CameraController(
    frontCamera,
    ResolutionPreset.medium,
    enableAudio: false,
  );
  await controller.initialize();

  bool challengePassed = false;
  int frameCount = 0;
  const maxFrames = 90; // 3 seconds at 30fps

  final faceDetector = FaceDetector(
    options: FaceDetectorOptions(
      enableClassification: true, // for blink/smile
      enableContours: true,
      enableLandmarks: true,
      enableTracking: true,
      performanceMode: FaceDetectorMode.accurate,
    ),
  );

  await controller.startImageStream((CameraImage image) async {
    if (frameCount++ > maxFrames || challengePassed) return;

    // Convert to InputImage and detect faces
    // (Platform-specific byte conversion omitted for brevity)
    // final faces = await faceDetector.processImage(inputImage);
    // if (faces.isEmpty) return;
    // final face = faces.first;

    // Validate challenge based on type
    // if (challengeType == 'blink') {
    //   challengePassed =
    //     (face.leftEyeOpenProbability ?? 1.0) < 0.2 &&
    //     (face.rightEyeOpenProbability ?? 1.0) < 0.2;
    // } else if (challengeType == 'smile') {
    //   challengePassed = (face.smilingProbability ?? 0.0) > 0.8;
    // } else if (challengeType == 'headTurnLeft') {
    //   challengePassed = (face.headEulerAngleY ?? 0.0) < -20;
    // } else if (challengeType == 'headTurnRight') {
    //   challengePassed = (face.headEulerAngleY ?? 0.0) > 20;
    // }
  });

  await Future.delayed(const Duration(seconds: 3));
  await controller.stopImageStream();
  await controller.dispose();
  await faceDetector.close();

  return challengePassed;
}
```

**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.

### 2. 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.

```
// Custom Action: authenticateWithFaceSecure
// Return type: String (success, locked, failed, error)
// Packages: cloud_firestore, firebase_auth, image_picker

Future<String> authenticateWithFaceSecure() async {
  final uid = FirebaseAuth.instance.currentUser?.uid;
  if (uid == null) return 'error:not_authenticated';

  // 1. Check lockout status server-side
  final userDoc = await FirebaseFirestore.instance
      .collection('users').doc(uid).get();
  final data = userDoc.data()!;

  final lockedUntilTs = data['faceAuthLockedUntil'] as Timestamp?;
  if (lockedUntilTs != null) {
    final lockedUntil = lockedUntilTs.toDate();
    if (DateTime.now().isBefore(lockedUntil)) {
      final remaining = lockedUntil.difference(DateTime.now());
      final minutes = remaining.inMinutes + 1;
      return 'locked:Face authentication locked for $minutes more minutes';
    } else {
      // Lockout expired — clear it
      await FirebaseFirestore.instance
          .collection('users').doc(uid).update({
        'faceAuthLockedUntil': FieldValue.delete(),
        'faceAuthFailedAttempts': 0,
      });
    }
  }

  // 2. Capture photo and extract embedding
  final picker = ImagePicker();
  final photo = await picker.pickImage(
    source: ImageSource.camera,
    preferredCameraDevice: CameraDevice.front,
    imageQuality: 85,
  );
  if (photo == null) return 'cancelled';

  final bytes = await photo.readAsBytes();
  final base64Image = base64Encode(bytes);

  try {
    final callable = FirebaseFunctions.instance
        .httpsCallable('extractFaceEmbedding');
    final result = await callable.call({'imageBase64': base64Image});
    final liveEmbedding = List<double>.from(
      (result.data['embedding'] as List).map((e) => (e as num).toDouble()),
    );
    final storedEmbedding = List<double>.from(
      (data['faceEmbedding'] as List).map((e) => (e as num).toDouble()),
    );

    final similarity = _cosineSimilarity(liveEmbedding, storedEmbedding);

    if (similarity >= 0.85) {
      // Success — reset failure count
      await FirebaseFirestore.instance
          .collection('users').doc(uid).update({
        'faceAuthFailedAttempts': 0,
        'faceAuthLockedUntil': FieldValue.delete(),
      });
      await _logFaceAuthEvent(uid, 'success', similarity);
      return 'success';
    } else {
      // Failure — increment count, check lockout threshold
      final newCount =
          ((data['faceAuthFailedAttempts'] as num?)?.toInt() ?? 0) + 1;
      final updates = <String, dynamic>{
        'faceAuthFailedAttempts': newCount,
      };
      if (newCount >= 3) {
        updates['faceAuthLockedUntil'] = Timestamp.fromDate(
          DateTime.now().add(const Duration(minutes: 30)),
        );
        updates['faceAuthFailedAttempts'] = 0;
      }
      await FirebaseFirestore.instance
          .collection('users').doc(uid).update(updates);
      await _logFaceAuthEvent(uid, 'failed', similarity);
      return newCount >= 3
          ? 'locked:Too many failed attempts. Locked for 30 minutes.'
          : 'failed:Face not recognized. ${3 - newCount} attempt(s) remaining.';
    }
  } on FirebaseFunctionsException catch (e) {
    await _logFaceAuthEvent(uid, 'error', 0);
    return 'error:${e.message}';
  }
}
```

**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.

### 3. 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.

### 4. 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.

```
// Helper: _logFaceAuthEvent (used inside Custom Actions)
// Call this after every face authentication outcome

Future<void> _logFaceAuthEvent(
  String uid,
  String eventType,
  double similarity,
) async {
  String deviceModel = 'unknown';
  try {
    final info = DeviceInfoPlugin();
    if (Platform.isAndroid) {
      final android = await info.androidInfo;
      deviceModel = '${android.manufacturer} ${android.model}';
    } else if (Platform.isIOS) {
      final ios = await info.iosInfo;
      deviceModel = ios.model;
    }
  } catch (_) {}

  await FirebaseFirestore.instance
      .collection('face_auth_events')
      .add({
    'userId': uid,
    'eventType': eventType,
    'similarity': similarity,
    'deviceModel': deviceModel,
    'timestamp': FieldValue.serverTimestamp(),
    'sessionId': DateTime.now().millisecondsSinceEpoch.toString(),
  });
}
```

**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 code example

File: `face_auth_security.dart`

```dart
// ============================================================
// FlutterFlow Face Recognition — Security Hardening
// ============================================================
// Cosine similarity helper (used by auth actions)
double _cosineSimilarity(List<double> a, List<double> b) {
  double dot = 0, normA = 0, normB = 0;
  for (int i = 0; i < a.length; i++) {
    dot += a[i] * b[i];
    normA += a[i] * a[i];
    normB += b[i] * b[i];
  }
  if (normA == 0 || normB == 0) return 0.0;
  return dot / (sqrt(normA) * sqrt(normB));
}

// Audit logging helper
Future<void> _logFaceAuthEvent(
    String uid, String eventType, double similarity) async {
  String deviceModel = 'unknown';
  try {
    if (Platform.isAndroid) {
      final info = await DeviceInfoPlugin().androidInfo;
      deviceModel = '${info.manufacturer} ${info.model}';
    } else if (Platform.isIOS) {
      final info = await DeviceInfoPlugin().iosInfo;
      deviceModel = info.model;
    }
  } catch (_) {}
  await FirebaseFirestore.instance.collection('face_auth_events').add({
    'userId': uid,
    'eventType': eventType,
    'similarity': similarity,
    'deviceModel': deviceModel,
    'timestamp': FieldValue.serverTimestamp(),
  });
}

// Lockout check helper — returns null if not locked, or lock message
Future<String?> _checkLockout(String uid) async {
  final doc = await FirebaseFirestore.instance
      .collection('users').doc(uid).get();
  final ts = doc.data()?['faceAuthLockedUntil'] as Timestamp?;
  if (ts == null) return null;
  final until = ts.toDate();
  if (DateTime.now().isBefore(until)) {
    final remaining = until.difference(DateTime.now()).inMinutes + 1;
    return 'Account locked for $remaining more minute(s). Try again later.';
  }
  // Lockout expired — clean up
  await FirebaseFirestore.instance.collection('users').doc(uid).update({
    'faceAuthLockedUntil': FieldValue.delete(),
    'faceAuthFailedAttempts': 0,
  });
  return null;
}

// Record a failure and apply lockout if threshold reached
Future<void> _recordFailure(String uid) async {
  final ref = FirebaseFirestore.instance.collection('users').doc(uid);
  final doc = await ref.get();
  final currentCount =
      ((doc.data()?['faceAuthFailedAttempts'] as num?)?.toInt() ?? 0) + 1;
  final updates = <String, dynamic>{
    'faceAuthFailedAttempts': currentCount
  };
  if (currentCount >= 3) {
    updates['faceAuthLockedUntil'] = Timestamp.fromDate(
      DateTime.now().add(const Duration(minutes: 30)),
    );
    updates['faceAuthFailedAttempts'] = 0;
  }
  await ref.update(updates);
}
```

## Common mistakes

- **Using face recognition as the ONLY authentication factor** — Face recognition can be spoofed with printed photos or screens, especially without liveness detection. If face auth is the sole factor, a determined attacker with just a photo of the target can gain account access. Single-factor biometric authentication also fails when users change appearance (glasses, injury, aging) with no recovery path. Fix: 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.
- **Storing lockout state only in App State instead of Firestore** — App State is cleared when the app is force-closed or uninstalled and reinstalled. A user who has been locked out can simply kill the app and reopen it to bypass the lockout entirely. Fix: 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.
- **Not providing a bypass path for users who cannot complete face authentication** — Users break their phone camera, wear new glasses, have a facial injury, or are using a new device without re-enrollment. If face auth is required and there is no bypass, these users are permanently locked out of their accounts. Fix: 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.
- **Skipping audit logging to save Firestore write costs** — Without an audit trail, you cannot detect brute-force attacks, investigate account compromises, respond to regulatory inquiries, or debug false rejection issues in production. The audit trail is not optional for a security feature. Fix: 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.
- **Displaying the cosine similarity score in a debug label visible to end users** — If users can see the similarity threshold (e.g., 'Your score: 0.82, threshold: 0.85'), attackers learn exactly how close their spoof attempt was and can calibrate higher-quality attacks iteratively. Fix: 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.

## 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).

---

Source: https://www.rapidevelopers.com/flutterflow-tutorials/how-to-implement-facial-recognition-for-enhanced-security-in-flutterflow
© RapidDev — https://www.rapidevelopers.com/flutterflow-tutorials/how-to-implement-facial-recognition-for-enhanced-security-in-flutterflow
