Authentification Biométrique en React Native avec Expo : Face ID, Empreinte et SecureStore (2026)

Intégrez Face ID, Touch ID et l'empreinte digitale dans votre app React Native avec expo-local-authentication et expo-secure-store. Flux complet, gestion des tokens chiffrés et pièges classiques à éviter en 2026.

Face ID & Touch ID React Native : Guide 2026

Soyons clairs : en 2026, l'authentification biométrique n'est plus un « plus », c'est devenu la norme. Plus rapide qu'un mot de passe, plus sûre qu'un code PIN à quatre chiffres, et surtout, c'est ce que les utilisateurs attendent. Si votre app touche à des données un peu sensibles — banque, santé, messagerie, gestionnaire de mots de passe ou même un simple espace personnel —, ne pas proposer Face ID ou l'empreinte digitale est devenu un vrai frein à l'adoption.

Dans ce guide, on va voir comment intégrer Face ID, Touch ID et l'empreinte digitale dans une app React Native avec Expo SDK 55, en s'appuyant sur expo-local-authentication côté auth et expo-secure-store côté stockage chiffré des tokens. Et au-delà du code, je vais surtout insister sur les pièges classiques — ceux que je vois en revue de code presque chaque semaine — qui transforment une « auth biométrique » en simple décor de sécurité.

Pourquoi la biométrie en 2026 ?

Plus de 95 % des smartphones vendus aujourd'hui sont équipés d'un capteur biométrique : Face ID, Touch ID, empreinte digitale ou reconnaissance d'iris. Côté utilisateur, l'expérience est instantanée — un regard, un doigt, et c'est ouvert. Côté développeur, attention : la biométrie ne remplace pas votre serveur d'authentification. Elle ajoute une couche locale qui protège des tokens déjà stockés sur l'appareil. C'est une distinction qui paraît évidente, mais qu'on oublie facilement quand on commence à bricoler.

Concrètement, voici ce que la biométrie résout réellement :

  • Re-authentification rapide : l'utilisateur ouvre l'app, prouve son identité localement, et accède à son token stocké dans le Keychain iOS ou le Keystore Android.
  • Protection contre l'accès physique : si quelqu'un récupère le téléphone déjà déverrouillé (genre, vous l'avez laissé sur la table d'un café), il ne peut pas ouvrir une app bancaire ou un coffre-fort de mots de passe sans biométrie valide.
  • Conformité : la PSD2 en Europe et plusieurs réglementations sectorielles imposent une authentification forte (SCA) pour certaines opérations financières.

Ce que la biométrie ne fait pas, en revanche : elle ne remplace ni votre serveur d'auth, ni le rafraîchissement des tokens, ni la rotation de secrets. Voyez-la comme un verrou local sur des credentials déjà obtenus via OAuth, JWT ou un magic link.

Architecture du flux d'authentification

Voici le flux qu'on va implémenter, et qui correspond aux bonnes pratiques 2026 utilisées par les apps de production :

  1. L'utilisateur se connecte une première fois avec email/mot de passe ou OAuth.
  2. Le serveur renvoie un accessToken et un refreshToken.
  3. L'app stocke le refreshToken dans expo-secure-store avec l'option requireAuthentication: true.
  4. À la prochaine ouverture, on demande la biométrie via expo-local-authentication.
  5. Si elle réussit, on lit le refreshToken chiffré et on obtient un nouvel accessToken.
  6. Si elle échoue ou si la biométrie a été invalidée (nouveau visage enrôlé, nouveau code PIN), on retombe sur le flux email/mot de passe.

Le point important, c'est qu'un attaquant qui dump le stockage de l'app ne récupère que des données chiffrées et inutilisables sans présence physique de l'utilisateur. C'est exactement ce qu'on veut.

Installation et configuration

Bon, on commence par un projet Expo neuf et l'installation des deux librairies clés :

npx create-expo-app@latest BiometricAuthApp --template default@sdk-55
cd BiometricAuthApp
npx expo install expo-local-authentication expo-secure-store

Ensuite, ouvrez app.json et ajoutez les permissions natives. C'est obligatoire sur iOS — sans NSFaceIDUsageDescription, le système ignore le capteur Face ID et retombe silencieusement sur le code PIN, sans rien vous dire. Croyez-moi, c'est l'une des sources de bugs les plus frustrantes en production, parce qu'en simulateur ça « marche » et c'est seulement quand un vrai utilisateur installe l'app que vous découvrez le problème.

{
  "expo": {
    "name": "BiometricAuthApp",
    "slug": "biometric-auth-app",
    "ios": {
      "bundleIdentifier": "com.example.biometricauth",
      "infoPlist": {
        "NSFaceIDUsageDescription": "Cette application utilise Face ID pour vous connecter de manière sécurisée."
      }
    },
    "android": {
      "package": "com.example.biometricauth"
    },
    "plugins": [
      [
        "expo-local-authentication",
        {
          "faceIDPermission": "Autorisez $(PRODUCT_NAME) à utiliser Face ID."
        }
      ],
      "expo-secure-store"
    ]
  }
}

Comme on utilise des modules natifs, Expo Go ne suffira pas. Il vous faudra un development build via EAS :

npm install -g eas-cli
eas login
eas build:configure
eas build --profile development --platform ios
eas build --profile development --platform android

Détecter la disponibilité biométrique

Avant même de proposer la biométrie à l'utilisateur, il faut vérifier trois choses : que le matériel est présent, que l'utilisateur a enrôlé au moins une donnée biométrique, et déterminer le type (Face ID, Touch ID, empreinte). Voici un hook React qui encapsule cette logique de manière propre :

// hooks/useBiometricAvailability.ts
import { useEffect, useState } from 'react';
import * as LocalAuthentication from 'expo-local-authentication';

export type BiometricType = 'face' | 'fingerprint' | 'iris' | 'none';

export type BiometricAvailability = {
  isAvailable: boolean;
  type: BiometricType;
  isEnrolled: boolean;
  securityLevel: LocalAuthentication.SecurityLevel;
};

export function useBiometricAvailability() {
  const [state, setState] = useState<BiometricAvailability | null>(null);

  useEffect(() => {
    (async () => {
      const hasHardware = await LocalAuthentication.hasHardwareAsync();
      const isEnrolled = await LocalAuthentication.isEnrolledAsync();
      const types = await LocalAuthentication.supportedAuthenticationTypesAsync();
      const securityLevel = await LocalAuthentication.getEnrolledLevelAsync();

      let type: BiometricType = 'none';
      if (types.includes(LocalAuthentication.AuthenticationType.FACIAL_RECOGNITION)) {
        type = 'face';
      } else if (types.includes(LocalAuthentication.AuthenticationType.FINGERPRINT)) {
        type = 'fingerprint';
      } else if (types.includes(LocalAuthentication.AuthenticationType.IRIS)) {
        type = 'iris';
      }

      setState({
        isAvailable: hasHardware && isEnrolled,
        type,
        isEnrolled,
        securityLevel,
      });
    })();
  }, []);

  return state;
}

Le champ securityLevel est crucial, et c'est honnêtement la partie que beaucoup de tutoriels passent sous silence. Il peut valoir NONE, SECRET (code PIN/motif), BIOMETRIC_WEAK (Classe 2) ou BIOMETRIC_STRONG (Classe 3). Pour des données vraiment sensibles — financières, santé, secrets professionnels — exigez BIOMETRIC_STRONG sans compromis. Les systèmes Class 2 incluent par exemple la reconnaissance faciale 2D simple sur certains Android d'entrée de gamme, et il a été démontré qu'on peut les tromper avec une simple photo imprimée. Pas top.

Authentifier l'utilisateur

L'authentification en elle-même tient en quelques lignes. Mais les options changent radicalement le comportement, et c'est précisément là que la plupart des tutoriels passent à côté :

// services/biometric.ts
import * as LocalAuthentication from 'expo-local-authentication';

export async function authenticateWithBiometrics(reason?: string) {
  const result = await LocalAuthentication.authenticateAsync({
    promptMessage: reason ?? 'Confirmez votre identité',
    fallbackLabel: 'Utiliser le code',
    cancelLabel: 'Annuler',
    disableDeviceFallback: false,
    requireConfirmation: false,
  });

  if (result.success) {
    return { success: true as const };
  }

  return {
    success: false as const,
    error: result.error,
    warning: 'warning' in result ? result.warning : undefined,
  };
}

Quelques options à connaître absolument :

  • disableDeviceFallback : si true, l'utilisateur ne peut pas retomber sur le code PIN du téléphone. Mettez true si vous voulez forcer une biométrie réelle (cas bancaire), sinon laissez sur false pour une meilleure UX.
  • requireConfirmation : sur Android avec Face Unlock, demande à l'utilisateur d'appuyer pour confirmer. Recommandé pour des opérations sensibles (paiement, suppression de compte).
  • cancelLabel : sur Android uniquement, change le libellé du bouton d'annulation.

Stocker les tokens avec SecureStore

expo-secure-store chiffre les valeurs avec le Keychain sur iOS et le Keystore Android. Mais l'option vraiment puissante — celle qui fait toute la différence — est requireAuthentication: true, qui lie la lecture de la valeur à une authentification biométrique active :

// services/secureStorage.ts
import * as SecureStore from 'expo-secure-store';

const REFRESH_TOKEN_KEY = 'refresh_token';

export const tokenStorage = {
  async save(refreshToken: string) {
    await SecureStore.setItemAsync(REFRESH_TOKEN_KEY, refreshToken, {
      requireAuthentication: true,
      authenticationPrompt: 'Confirmez pour enregistrer votre session',
      keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
    });
  },

  async load(): Promise<string | null> {
    try {
      return await SecureStore.getItemAsync(REFRESH_TOKEN_KEY, {
        requireAuthentication: true,
        authenticationPrompt: 'Deverrouillez votre session',
      });
    } catch (e) {
      // L'utilisateur a annule ou la biometrie a ete invalidee
      return null;
    }
  },

  async clear() {
    await SecureStore.deleteItemAsync(REFRESH_TOKEN_KEY);
  },
};

Avec requireAuthentication: true, la biométrie est demandée par le système au moment de la lecture. Vous n'avez donc même pas besoin d'appeler authenticateAsync séparément : c'est SecureStore lui-même qui déclenche le prompt natif. C'est plus sûr et plus performant qu'une approche en deux étapes (auth puis lecture), parce qu'il n'y a aucune fenêtre temporelle pendant laquelle le token déchiffré traîne en mémoire avant d'être utilisé.

L'option WHEN_UNLOCKED_THIS_DEVICE_ONLY garantit que la valeur n'est jamais sauvegardée dans iCloud Keychain et reste liée physiquement à l'appareil. Concrètement, ça empêche son extraction lors d'une restauration sur un autre iPhone — un détail qui compte beaucoup pour les apps qui doivent répondre à des exigences de conformité strictes.

Mettre tout ensemble : un flux de connexion complet

Voici un écran de connexion qui orchestre l'ensemble du flux. Il propose la biométrie si disponible, retombe sur email/mot de passe sinon, et gère les cas d'invalidation :

// app/login.tsx
import { useEffect, useState } from 'react';
import { View, Text, TextInput, Pressable, Alert } from 'react-native';
import { router } from 'expo-router';
import { useBiometricAvailability } from '../hooks/useBiometricAvailability';
import { tokenStorage } from '../services/secureStorage';
import { authenticateWithBiometrics } from '../services/biometric';
import { loginWithCredentials, refreshSession } from '../services/api';

export default function LoginScreen() {
  const biometric = useBiometricAvailability();
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    if (biometric?.isAvailable) {
      tryBiometricLogin();
    }
  }, [biometric?.isAvailable]);

  const tryBiometricLogin = async () => {
    const refreshToken = await tokenStorage.load();
    if (!refreshToken) return;

    try {
      await refreshSession(refreshToken);
      router.replace('/home');
    } catch {
      await tokenStorage.clear();
      Alert.alert('Session expiree', 'Veuillez vous reconnecter.');
    }
  };

  const handlePasswordLogin = async () => {
    setLoading(true);
    try {
      const { refreshToken } = await loginWithCredentials(email, password);

      if (biometric?.isAvailable) {
        const auth = await authenticateWithBiometrics(
          'Activez la connexion biometrique'
        );
        if (auth.success) {
          await tokenStorage.save(refreshToken);
        }
      }

      router.replace('/home');
    } catch (e) {
      Alert.alert('Erreur', 'Identifiants invalides');
    } finally {
      setLoading(false);
    }
  };

  const biometricLabel =
    biometric?.type === 'face' ? 'Face ID' :
    biometric?.type === 'fingerprint' ? 'Empreinte' : 'Biometrie';

  return (
    <View style={{ padding: 24, gap: 12 }}>
      <Text style={{ fontSize: 24, fontWeight: 'bold' }}>Connexion</Text>

      <TextInput
        placeholder="Email"
        value={email}
        onChangeText={setEmail}
        autoCapitalize="none"
        keyboardType="email-address"
      />
      <TextInput
        placeholder="Mot de passe"
        value={password}
        onChangeText={setPassword}
        secureTextEntry
      />

      <Pressable onPress={handlePasswordLogin} disabled={loading}>
        <Text>Se connecter</Text>
      </Pressable>

      {biometric?.isAvailable && (
        <Pressable onPress={tryBiometricLogin}>
          <Text>Utiliser {biometricLabel}</Text>
        </Pressable>
      )}
    </View>
  );
}

Gérer l'invalidation biométrique

Quand l'utilisateur ajoute un nouveau visage à Face ID, modifie ses empreintes ou change son code PIN, le système invalide automatiquement les clés liées avec requireAuthentication: true. C'est une fonctionnalité de sécurité, mais elle peut surprendre la première fois : la prochaine lecture de SecureStore lèvera une erreur, et l'utilisateur devra se reconnecter. Pas un bug, une feature.

Comportement recommandé :

  • Capturez l'erreur de lecture sans la considérer comme un crash.
  • Affichez un message clair : « Votre biométrie a changé, veuillez vous reconnecter ».
  • Effacez les anciens tokens avec tokenStorage.clear().
  • Redirigez vers le flux email/mot de passe.

Pièges classiques à éviter en 2026

Voici les erreurs que je rencontre le plus souvent en revue de code, et qui transforment une « auth biométrique » en pur théâtre de sécurité :

Anti-patternPourquoi c'est dangereuxSolution
Stocker le token dans AsyncStorage et juste « gater » l'écran avec un prompt biométrique Un dump du device récupère le token en clair, la biométrie est purement décorative Utilisez SecureStore avec requireAuthentication: true
Encoder le token en Base64 avant de le sauvegarder Base64 est un encodage, pas un chiffrement. C'est réversible en une commande Laissez SecureStore chiffrer via Keychain/Keystore
Stocker un mot de passe maître dans SecureStore Si la clé est compromise, le mot de passe l'est définitivement Stockez uniquement des refresh tokens rotables côté serveur
Ignorer getEnrolledLevelAsync et accepter BIOMETRIC_WEAK Class 2 sur Android est trompable avec une photo Exigez BIOMETRIC_STRONG pour les actions sensibles
Oublier NSFaceIDUsageDescription dans app.json iOS retombe silencieusement sur le code, sans avertir l'utilisateur Ajoutez la description et testez sur un vrai appareil

Tester en local et en production

Petite astuce : sur un simulateur iOS, vous pouvez tester Face ID sans avoir d'iPhone Pro. Allez dans le menu Features → Face ID → Enrolled, puis utilisez Matching Face / Non-Matching Face pour simuler un succès ou un échec. Sur un émulateur Android, ouvrez Settings → Security → Fingerprint, et utilisez la commande adb -e emu finger touch 1 pour simuler une empreinte. Très pratique pour les tests automatisés.

En production, voici les métriques que je recommande de surveiller :

  • Taux de succès biométrique vs fallback PIN
  • Taux d'erreurs UserCancel vs SystemCancel vs Lockout
  • Pourcentage d'utilisateurs avec isEnrolled === false — pour calibrer correctement votre flux secondaire

FAQ

L'authentification biométrique fonctionne-t-elle dans Expo Go ?

Non, et c'est une question qui revient tout le temps. expo-local-authentication et expo-secure-store avec requireAuthentication nécessitent un development build créé via EAS Build. Expo Go ne charge pas ces modules natifs avec leurs configurations personnalisées. C'est une étape obligatoire dès que vous quittez le prototypage.

Quelle est la différence entre expo-local-authentication et react-native-biometrics ?

expo-local-authentication est intégré nativement à l'écosystème Expo, fonctionne avec EAS et expose une API uniforme iOS/Android. react-native-biometrics est plus bas niveau et expose la création de paires de clés cryptographiques liées à la biométrie, utile pour des scénarios très sécurisés où vous voulez signer des requêtes serveur. Pour 95 % des applications, expo-local-authentication couplé à expo-secure-store est largement suffisant et beaucoup plus simple à maintenir.

Comment gérer un utilisateur qui n'a pas de biométrie enrôlée ?

Toujours, toujours proposer un flux de fallback email/mot de passe ou OAuth. Vérifiez isEnrolledAsync() au démarrage et conditionnez l'affichage du bouton biométrique à ce résultat. N'imposez jamais la biométrie : certains utilisateurs ont des raisons parfaitement légitimes de ne pas l'utiliser — préférence personnelle, handicap, partage d'appareil familial, etc.

Que se passe-t-il si l'utilisateur change son visage ou son empreinte ?

Toutes les valeurs SecureStore stockées avec requireAuthentication: true sont automatiquement invalidées par iOS et Android. La prochaine lecture lèvera une erreur. C'est une protection volontaire contre les scénarios où un attaquant ajouterait son propre visage à un téléphone volé. Capturez l'erreur, effacez le token et redirigez vers la connexion classique.

La biométrie remplace-t-elle l'authentification serveur ?

Non, jamais. Et c'est probablement le malentendu le plus dangereux qu'on rencontre. La biométrie est un mécanisme de déverrouillage local. Votre serveur doit continuer à émettre, vérifier et faire tourner des tokens via OAuth, JWT ou un protocole équivalent. La biométrie protège les tokens stockés sur l'appareil ; elle n'authentifie pas l'utilisateur auprès de votre backend. Voyez-la comme un coffre-fort qui garde vos credentials, pas comme un mot de passe en soi.

Conclusion

Avec expo-local-authentication et expo-secure-store, intégrer une authentification biométrique solide à une app React Native demande moins de 200 lignes de code en 2026. Le secret n'est pas dans la complexité du code, mais dans le respect de quelques principes simples : utilisez requireAuthentication: true, exigez BIOMETRIC_STRONG pour les opérations sensibles, prévoyez un fallback email/mot de passe, et gérez explicitement l'invalidation biométrique.

Le code de cet article est directement utilisable en production. Adaptez-le à votre flux d'authentification serveur (OAuth, magic link, JWT) et vous offrirez à vos utilisateurs une expérience sécurisée à la hauteur des attentes 2026 — sans jamais sacrifier la sécurité réelle au profit de la simplicité apparente. Et c'est exactement ce qui distingue une app qu'on garde de celle qu'on désinstalle après deux jours.

À propos de l'auteur Editorial Team

Our team of expert writers and editors.