Xác Thực Sinh Trắc Học React Native: Face ID, Touch ID Và Bảo Mật Token Với Expo

Hướng dẫn triển khai Face ID, Touch ID trong React Native với Expo. Bao gồm kiểm tra phần cứng, expo-local-authentication, lưu token mã hóa bằng expo-secure-store, xử lý timeout phiên và các lưu ý bảo mật quan trọng theo từng nền tảng.

Tại Sao Xác Thực Sinh Trắc Học Lại Quan Trọng Đến Vậy?

Nói thật, nếu bạn đang làm app mobile vào năm 2026 mà chưa tích hợp sinh trắc học thì... hơi muộn rồi đấy. Face ID và vân tay đã trở thành tiêu chuẩn mặc định — từ app ngân hàng, ví điện tử cho đến cả mạng xã hội. Người dùng giờ kỳ vọng mở app lên, quét mặt hoặc chạm ngón tay, xong.

Nhưng có một điều cực kỳ quan trọng cần nắm: dữ liệu sinh trắc học không bao giờ được lộ ra cho ứng dụng. Mọi quyết định xác thực đều do hệ điều hành xử lý, và dữ liệu nhạy cảm nằm trong phần cứng bảo mật chuyên dụng — Secure Enclave trên iOS và TEE (Trusted Execution Environment) trên Android. App chỉ nhận được "thành công" hoặc "thất bại", vậy thôi.

Trong bài này, mình sẽ hướng dẫn bạn triển khai xác thực sinh trắc học hoàn chỉnh trong React Native với Expo — từ kiểm tra phần cứng, xác thực người dùng, cho đến lưu trữ token an toàn bằng expo-secure-store. Mình đã dùng flow này trong vài dự án thực tế và nó chạy khá ổn.

Kiến Trúc Bảo Mật Sinh Trắc Học Trên Di Động

Trước khi nhảy vào code, hãy dành chút thời gian hiểu cách sinh trắc học hoạt động bên dưới. Tin mình đi, hiểu phần này sẽ giúp bạn debug dễ hơn rất nhiều sau này.

  • iOS (Secure Enclave): Face ID và Touch ID được xử lý bởi chip bảo mật riêng biệt. Ứng dụng chỉ nhận kết quả "thành công" hoặc "thất bại" — không bao giờ truy cập được dữ liệu vân tay hay khuôn mặt thực tế.
  • Android (TEE/StrongBox): Android phân loại sinh trắc học thành 3 cấp — Class 1 (yếu), Class 2 (trung bình), và Class 3 (mạnh). Chỉ Class 3 mới đủ an toàn cho các thao tác nhạy cảm kiểu xác nhận thanh toán.

Expo cung cấp lớp trừu tượng qua expo-local-authentication, cho phép bạn tương tác với cả hai nền tảng qua một API duy nhất mà không cần viết native code. Tiện lắm.

Cài Đặt Và Cấu Hình Thư Viện

Cài đặt các package cần thiết

Bạn cần hai thư viện chính (cộng thêm một cái phụ):

npx expo install expo-local-authentication expo-secure-store expo-device
  • expo-local-authentication — API xác thực sinh trắc học (Face ID, vân tay)
  • expo-secure-store — lưu trữ mã hóa key-value trên thiết bị
  • expo-device — phát hiện thiết bị thật hay máy ảo (quan trọng hơn bạn nghĩ đấy)

Cấu hình app.json

Thêm config plugin để khai báo quyền truy cập Face ID trên iOS. Bước này hay bị bỏ qua và gây ra lỗi khó hiểu, nên đừng quên nhé:

{
  "expo": {
    "plugins": [
      [
        "expo-local-authentication",
        {
          "faceIDPermission": "Ứng dụng cần Face ID để xác thực danh tính của bạn."
        }
      ],
      [
        "expo-secure-store",
        {
          "faceIDPermission": "Ứng dụng cần Face ID để truy cập dữ liệu được bảo vệ."
        }
      ]
    ]
  }
}

Lưu ý: Nếu thiếu khai báo NSFaceIDUsageDescription (qua config plugin hoặc Info.plist), iOS sẽ âm thầm chặn Face ID và chỉ hiển thị PIN. Mình đã mất cả buổi chiều debug lỗi này lần đầu gặp — đừng lặp lại sai lầm đó.

Kiểm Tra Khả Năng Sinh Trắc Học Của Thiết Bị

Trước khi hiển thị tùy chọn sinh trắc học cho người dùng, bạn phải kiểm tra ba điều kiện. Đây là bước mà nhiều người hay bỏ qua rồi tự hỏi sao app crash trên thiết bị thật:

import * as LocalAuthentication from 'expo-local-authentication';
import * as Device from 'expo-device';

async function checkBiometricSupport() {
  // 1. Kiểm tra có phải thiết bị thật không (sinh trắc không hoạt động trên simulator)
  if (!Device.isDevice) {
    return { supported: false, reason: 'Cần thiết bị thật để sử dụng sinh trắc học' };
  }

  // 2. Kiểm tra phần cứng có hỗ trợ không
  const hasHardware = await LocalAuthentication.hasHardwareAsync();
  if (!hasHardware) {
    return { supported: false, reason: 'Thiết bị không có phần cứng sinh trắc học' };
  }

  // 3. Kiểm tra người dùng đã đăng ký sinh trắc học chưa
  const isEnrolled = await LocalAuthentication.isEnrolledAsync();
  if (!isEnrolled) {
    return { supported: false, reason: 'Chưa thiết lập vân tay hoặc Face ID trên thiết bị' };
  }

  // 4. Kiểm tra cấp độ bảo mật (quan trọng trên Android)
  const level = await LocalAuthentication.getEnrolledLevelAsync();
  const isStrong = level === LocalAuthentication.SecurityLevel.BIOMETRIC_STRONG;

  return { supported: true, isStrong };
}

Hàm getEnrolledLevelAsync() trả về cấp độ bảo mật: NONE, SECRET (PIN/pattern), BIOMETRIC_WEAK (Class 2), hoặc BIOMETRIC_STRONG (Class 3). Nếu app của bạn liên quan đến tài chính hay thanh toán, nên yêu cầu BIOMETRIC_STRONG — đừng chấp nhận mức thấp hơn.

Triển Khai Luồng Xác Thực Sinh Trắc Học

Xác thực cơ bản

Phần này khá straightforward. Gọi authenticateAsync và xử lý kết quả:

async function authenticateWithBiometrics() {
  const result = await LocalAuthentication.authenticateAsync({
    promptMessage: 'Xác thực để tiếp tục',
    fallbackLabel: 'Dùng mật khẩu',
    cancelLabel: 'Hủy',
    disableDeviceFallback: false,
  });

  if (result.success) {
    console.log('Xác thực thành công');
    return true;
  }

  // Xử lý các lỗi cụ thể
  switch (result.error) {
    case 'user_cancel':
      console.log('Người dùng hủy xác thực');
      break;
    case 'lockout':
      console.log('Quá nhiều lần thử — thiết bị bị khóa tạm thời');
      break;
    case 'user_fallback':
      console.log('Người dùng chọn dùng mật khẩu thay thế');
      break;
    default:
      console.log('Lỗi xác thực:', result.error);
  }
  return false;
}

Các giá trị lỗi bạn sẽ gặp nhiều nhất: user_cancel, lockout (thiết bị khóa sau nhiều lần thất bại — thường là 5 lần), user_fallback (người dùng chọn nhập mật khẩu thay thế), và not_enrolled. Nhớ handle hết nhé, đừng để app im lặng khi xảy ra lỗi.

Kiểm tra loại sinh trắc học được hỗ trợ

Đoạn này hữu ích khi bạn muốn hiển thị icon phù hợp — icon vân tay cho Android hay icon Face ID cho iPhone đời mới chẳng hạn:

async function getSupportedBiometricTypes() {
  const types = await LocalAuthentication.supportedAuthenticationTypesAsync();

  const typeNames = types.map((type) => {
    switch (type) {
      case LocalAuthentication.AuthenticationType.FINGERPRINT:
        return 'Vân tay';
      case LocalAuthentication.AuthenticationType.FACIAL_RECOGNITION:
        return 'Nhận diện khuôn mặt';
      case LocalAuthentication.AuthenticationType.IRIS:
        return 'Quét mống mắt';
      default:
        return 'Không xác định';
    }
  });

  return typeNames;
}

Lưu Trữ Token An Toàn Với expo-secure-store

expo-secure-store mã hóa dữ liệu bằng Keychain (iOS) và Android Keystore. Nói đơn giản là token của bạn sẽ được bảo vệ ở mức hệ điều hành — ngay cả khi thiết bị bị root/jailbreak cũng rất khó đọc được.

Lưu và đọc token cơ bản

import * as SecureStore from 'expo-secure-store';

// Lưu token sau khi đăng nhập thành công
async function saveAuthToken(token: string) {
  await SecureStore.setItemAsync('auth_token', token);
}

// Đọc token khi cần
async function getAuthToken(): Promise<string | null> {
  return await SecureStore.getItemAsync('auth_token');
}

// Xóa token khi đăng xuất
async function removeAuthToken() {
  await SecureStore.deleteItemAsync('auth_token');
}

Kết hợp Secure Store với sinh trắc học

Đây là tính năng mình thích nhất — bạn có thể yêu cầu xác thực sinh trắc học mỗi khi đọc một giá trị từ Secure Store. Nghĩa là ngay cả khi ai đó có access vào app, họ vẫn không đọc được token mà không xác thực:

// Lưu token yêu cầu xác thực sinh trắc mỗi lần đọc
async function saveProtectedToken(token: string) {
  await SecureStore.setItemAsync('protected_token', token, {
    requireAuthentication: true,
  });
}

// Đọc token — tự động hiển thị prompt sinh trắc học
async function getProtectedToken(): Promise<string | null> {
  try {
    // Hệ thống sẽ tự động yêu cầu Face ID / vân tay
    return await SecureStore.getItemAsync('protected_token', {
      requireAuthentication: true,
    });
  } catch (error) {
    console.log('Không thể đọc token bảo vệ:', error);
    return null;
  }
}

Cảnh báo quan trọng mà bạn cần biết: Khi người dùng thay đổi dữ liệu sinh trắc học (thêm vân tay mới, reset Face ID), tất cả key được lưu với requireAuthentication: true sẽ bị hệ thống vô hiệu hóa. Token mất hẳn, không recover được. Bạn phải handle trường hợp này bằng cách yêu cầu đăng nhập lại bằng mật khẩu.

Xây Dựng Luồng Xác Thực Hoàn Chỉnh

OK, giờ ghép tất cả lại. Đây là ví dụ thực tế: đăng nhập bằng mật khẩu lần đầu, sau đó dùng sinh trắc học cho các lần mở app tiếp theo. Flow này khá phổ biến trong hầu hết các app production.

import React, { useState, useEffect, useCallback } from 'react';
import { View, Text, TextInput, TouchableOpacity, Alert, StyleSheet } from 'react-native';
import * as LocalAuthentication from 'expo-local-authentication';
import * as SecureStore from 'expo-secure-store';

const AUTH_TOKEN_KEY = 'user_auth_token';
const BIOMETRIC_ENABLED_KEY = 'biometric_enabled';

export default function AuthScreen() {
  const [isAuthenticated, setIsAuthenticated] = useState(false);
  const [biometricAvailable, setBiometricAvailable] = useState(false);
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  useEffect(() => {
    initAuth();
  }, []);

  const initAuth = async () => {
    // Kiểm tra sinh trắc học
    const hasHardware = await LocalAuthentication.hasHardwareAsync();
    const isEnrolled = await LocalAuthentication.isEnrolledAsync();
    setBiometricAvailable(hasHardware && isEnrolled);

    // Kiểm tra token đã lưu và thử đăng nhập bằng sinh trắc học
    const savedToken = await SecureStore.getItemAsync(AUTH_TOKEN_KEY);
    const biometricEnabled = await SecureStore.getItemAsync(BIOMETRIC_ENABLED_KEY);

    if (savedToken && biometricEnabled === 'true') {
      attemptBiometricLogin();
    }
  };

  const attemptBiometricLogin = async () => {
    const result = await LocalAuthentication.authenticateAsync({
      promptMessage: 'Đăng nhập bằng sinh trắc học',
      fallbackLabel: 'Dùng mật khẩu',
      cancelLabel: 'Hủy',
    });

    if (result.success) {
      const token = await SecureStore.getItemAsync(AUTH_TOKEN_KEY);
      if (token) {
        setIsAuthenticated(true);
      }
    }
  };

  const handleLogin = async () => {
    // Gọi API đăng nhập (thay bằng API thật của bạn)
    const token = await fakeLoginAPI(email, password);
    if (!token) {
      Alert.alert('Lỗi', 'Email hoặc mật khẩu không đúng');
      return;
    }

    // Lưu token an toàn
    await SecureStore.setItemAsync(AUTH_TOKEN_KEY, token);
    setIsAuthenticated(true);

    // Đề xuất bật sinh trắc học
    if (biometricAvailable) {
      Alert.alert(
        'Đăng nhập nhanh',
        'Bạn có muốn dùng vân tay/Face ID cho lần đăng nhập sau?',
        [
          { text: 'Để sau', style: 'cancel' },
          {
            text: 'Bật ngay',
            onPress: async () => {
              await SecureStore.setItemAsync(BIOMETRIC_ENABLED_KEY, 'true');
            },
          },
        ]
      );
    }
  };

  const handleLogout = async () => {
    await SecureStore.deleteItemAsync(AUTH_TOKEN_KEY);
    await SecureStore.deleteItemAsync(BIOMETRIC_ENABLED_KEY);
    setIsAuthenticated(false);
  };

  if (isAuthenticated) {
    return (
      <View style={styles.container}>
        <Text style={styles.title}>Chào mừng bạn đã đăng nhập!</Text>
        <TouchableOpacity style={styles.button} onPress={handleLogout}>
          <Text style={styles.buttonText}>Đăng xuất</Text>
        </TouchableOpacity>
      </View>
    );
  }

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Đăng nhập</Text>
      <TextInput
        style={styles.input}
        placeholder="Email"
        value={email}
        onChangeText={setEmail}
        autoCapitalize="none"
        keyboardType="email-address"
      />
      <TextInput
        style={styles.input}
        placeholder="Mật khẩu"
        value={password}
        onChangeText={setPassword}
        secureTextEntry
      />
      <TouchableOpacity style={styles.button} onPress={handleLogin}>
        <Text style={styles.buttonText}>Đăng nhập</Text>
      </TouchableOpacity>

      {biometricAvailable && (
        <TouchableOpacity
          style={[styles.button, styles.biometricButton]}
          onPress={attemptBiometricLogin}
        >
          <Text style={styles.buttonText}>Đăng nhập bằng sinh trắc học</Text>
        </TouchableOpacity>
      )}
    </View>
  );
}

// Hàm giả lập API — thay bằng API thật
async function fakeLoginAPI(email: string, password: string) {
  if (email && password) return 'fake-jwt-token-abc123';
  return null;
}

const styles = StyleSheet.create({
  container: { flex: 1, justifyContent: 'center', padding: 24 },
  title: { fontSize: 24, fontWeight: 'bold', textAlign: 'center', marginBottom: 32 },
  input: {
    borderWidth: 1, borderColor: '#ccc', borderRadius: 8,
    padding: 12, marginBottom: 16, fontSize: 16,
  },
  button: {
    backgroundColor: '#007AFF', borderRadius: 8,
    padding: 16, alignItems: 'center', marginBottom: 12,
  },
  biometricButton: { backgroundColor: '#34C759' },
  buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
});

Xử Lý Timeout Và Phiên Không Hoạt Động

Đây là phần nhiều dev hay quên. Bạn nên yêu cầu xác thực lại sau khi người dùng không tương tác trong một khoảng thời gian nhất định.

Qua kinh nghiệm thực tế, 10 phút là mốc cân bằng tốt nhất — dưới 5 phút thì gây phiền cho người dùng, trên 15 phút thì tăng rủi ro bảo mật. Tất nhiên, nếu app bạn là ví điện tử thì có thể cần strict hơn.

import { useRef, useEffect } from 'react';
import { AppState, AppStateStatus } from 'react-native';

const INACTIVITY_TIMEOUT = 10 * 60 * 1000; // 10 phút

function useInactivityLock(onLock: () => void) {
  const backgroundTime = useRef<number | null>(null);

  useEffect(() => {
    const subscription = AppState.addEventListener(
      'change',
      (nextState: AppStateStatus) => {
        if (nextState === 'background') {
          backgroundTime.current = Date.now();
        } else if (nextState === 'active' && backgroundTime.current) {
          const elapsed = Date.now() - backgroundTime.current;
          if (elapsed > INACTIVITY_TIMEOUT) {
            onLock(); // Yêu cầu xác thực lại
          }
          backgroundTime.current = null;
        }
      }
    );

    return () => subscription.remove();
  }, [onLock]);
}

Hook useInactivityLock theo dõi thời gian ứng dụng ở background. Khi người dùng quay lại sau 10 phút, callback onLock được gọi để hiển thị lại màn hình xác thực. Đơn giản nhưng hiệu quả.

Lưu Ý Quan Trọng Theo Từng Nền Tảng

iOS

  • Face ID không hoạt động trong Expo Go — bạn cần tạo Development Build bằng npx expo run:ios hoặc eas build --profile development. Đây là "bẫy" phổ biến nhất mà mình thấy trên các forum.
  • Bắt buộc khai báo NSFaceIDUsageDescription qua config plugin hoặc Info.plist. Không có nó thì Face ID bị chặn âm thầm.
  • Có một bug đã biết: prompt Face ID khi dùng SecureStore.setItemAsync với requireAuthentication: true đôi khi không hiển thị ở lần gọi đầu tiên trên một số phiên bản iOS. Workaround là thử gọi lại.

Android

  • Quyền USE_BIOMETRICUSE_FINGERPRINT được tự động thêm khi cài expo-local-authentication, nên bạn không cần lo phần này.
  • Luôn kiểm tra getEnrolledLevelAsync() — một số thiết bị Android (đặc biệt các dòng giá rẻ) chỉ hỗ trợ sinh trắc học Class 2, không đủ an toàn cho giao dịch tài chính.
  • Bug trên một số thiết bị Samsung: SecureStore với requireAuthentication: true có thể báo lỗi nếu chỉ đăng ký nhận diện khuôn mặt mà không có vân tay. Hơi khó chịu nhưng đó là thực tế.

Các Nguyên Tắc Bảo Mật Cần Nhớ

Sau khi triển khai xong phần kỹ thuật, đây là những nguyên tắc bạn nên khắc cốt ghi tâm:

  1. Luôn có phương thức dự phòng: Đặt disableDeviceFallback: false để cho phép dùng PIN/mật khẩu khi cảm biến hỏng hoặc tay ướt. Đừng bao giờ khóa người dùng ra ngoài app.
  2. Không dùng AsyncStorage cho token: AsyncStorage lưu dữ liệu dạng plain text. Dùng nó để lưu token là mời hacker vào nhà rồi mở cửa cho họ luôn. Luôn dùng expo-secure-store.
  3. Sinh trắc học bổ trợ, không thay thế backend: Xác thực sinh trắc chỉ mở khóa token cục bộ — backend vẫn phải xác minh token mỗi request. Đây là lớp bảo mật bổ sung, không phải thay thế.
  4. Xử lý thay đổi sinh trắc học: Khi người dùng thêm/xóa vân tay, token được bảo vệ sẽ bị vô hiệu. Bắt lỗi này và yêu cầu đăng nhập lại.
  5. OAuth nên dùng PKCE: Theo RFC 8252, ứng dụng native phải dùng Authorization Code Flow với PKCE thay vì Implicit Flow. Không có ngoại lệ.
  6. Không log thông tin nhạy cảm: Tránh console.log token, lỗi chi tiết hoặc dữ liệu cá nhân trong production. Tưởng nhỏ nhưng nhiều app lớn đã dính lỗi này.

Câu Hỏi Thường Gặp

Face ID có hoạt động trong Expo Go không?

Không. Face ID trên iOS không được hỗ trợ trong Expo Go vì thiếu entitlement NSFaceIDUsageDescription. Bạn cần tạo Development Build bằng eas build --profile development --platform ios hoặc npx expo run:ios.

Tại sao thiết bị hiển thị PIN thay vì Face ID?

Ba nguyên nhân phổ biến nhất: (1) bạn đang dùng Expo Go thay vì Development Build, (2) thiếu khai báo NSFaceIDUsageDescription trong app.json, hoặc (3) người dùng chưa thiết lập Face ID trong Settings. Kiểm tra bằng isEnrolledAsync() trước khi gọi authenticateAsync() là cách đơn giản nhất để xác định vấn đề.

Dữ liệu vân tay hoặc khuôn mặt có bị gửi lên server không?

Tuyệt đối không. Dữ liệu sinh trắc học được xử lý hoàn toàn trên thiết bị bởi hệ điều hành. App chỉ nhận kết quả thành công hoặc thất bại — không truy cập được dữ liệu gốc. Đây là thiết kế có chủ đích của cả Apple lẫn Google.

Nên dùng expo-local-authentication hay react-native-biometrics?

expo-local-authentication phù hợp nhất nếu bạn dùng Expo managed workflow hoặc CNG. Còn react-native-biometrics thì phù hợp hơn cho bare React Native khi cần tạo key pair để xác minh với backend. Cả hai đều hoạt động tốt trên cả iOS và Android, nên chọn cái nào phù hợp với stack của bạn thôi.

Điều gì xảy ra khi người dùng thêm vân tay mới?

Nếu bạn lưu giá trị với requireAuthentication: true trong expo-secure-store, key đó sẽ bị hệ thống vô hiệu hóa khi sinh trắc học thay đổi. Giá trị mất hẳn, không đọc lại được. Giải pháp: bắt lỗi khi đọc token, nếu thất bại thì yêu cầu người dùng đăng nhập lại bằng mật khẩu và lưu token mới.

Về Tác Giả Editorial Team

Our team of expert writers and editors.