Nota sul codice: gli endpoint usati negli esempi sono contratti illustrativi. Nel progetto myGenius il flusso attuale usa react-native-keychain per proteggere un refresh JWT; qui mostriamo l’implementazione più completa basata su chiavi native e firma di una challenge, coerente con l’architettura descritta nell’articolo precedente.
Dal concetto all’implementazione
Nel precedente articolo abbiamo visto perché la biometria non dovrebbe essere trattata come una semplice password alternativa. Face ID e Touch ID non devono consegnare al server un volto, un’impronta o un valore statico: devono autorizzare l’uso di una risorsa protetta dal sistema operativo.
In questa guida trasformiamo quel principio in un flusso React Native concreto. L’obiettivo è costruire un piccolo adapter, biometricHelper, che sappia:
- verificare se il dispositivo può usare la biometria;
- creare una coppia di chiavi nel keystore nativo;
- registrare la chiave pubblica presso il backend;
- firmare una challenge dopo l’autenticazione dell’utente;
- gestire il fallback sulla credenziale del dispositivo;
- revocare le chiavi locali quando l’utente disabilita la funzione.
La schermata non dovrebbe conoscere i dettagli del Keychain, del Keystore o della firma RSA. Dovrebbe limitarsi a chiamare funzioni con nomi comprensibili e a mostrare lo stato corretto.
La libreria: react-native-biometrics
Per questo esempio utilizziamo react-native-biometrics, una libreria che espone un bridge verso le funzionalità native di iOS e Android. La sua API documentata comprende isSensorAvailable(), createKeys(), biometricKeysExist(), createSignature() e deleteKeys().
La parte interessante non è soltanto il prompt. La libreria genera una coppia di chiavi: la chiave privata resta nel keystore del dispositivo e la chiave pubblica può essere inviata al backend. Al login successivo, il server invia una challenge e il dispositivo la firma solo dopo che l’utente ha superato il controllo biometrico.
La dipendenza va comunque valutata prima dell’adozione definitiva: compatibilità con la versione di React Native, requisiti minimi delle piattaforme, stato di manutenzione e supporto per la New Architecture devono essere verificati nel momento in cui si installa il pacchetto. Per questo conviene isolare tutta l’integrazione in un solo helper, senza importare la libreria direttamente nelle screen.
L’installazione di base è:
npm install react-native-biometrics
npx pod-install
In un progetto bare React Native è inoltre necessario completare la configurazione nativa. Su iOS bisogna dichiarare il motivo per cui l’app utilizza Face ID con NSFaceIDUsageDescription. Su Android bisogna verificare i requisiti del modulo e le modalità di autenticazione supportate dal dispositivo.
Il contratto del backend viene prima del codice
Prima di scrivere l’helper bisogna definire cosa si scambiano app e server. Un contratto minimo può prevedere tre operazioni:
POST /biometric-keys
registra la chiave pubblica per l'utente e il dispositivo
GET /biometric/challenge
restituisce una challenge casuale con scadenza breve
POST /biometric/verify
verifica firma, challenge, account e dispositivo
La challenge non deve essere generata dal client e non deve essere riutilizzabile. Una risposta potrebbe avere questa forma:
{
"challenge": "random-server-value",
"expiresAt": "2026-08-03T12:00:00Z"
}
La richiesta di verifica dovrebbe includere almeno:
{
"challenge": "random-server-value",
"signature": "base64-signature",
"keyId": "device-key-id"
}
Il server deve controllare che la challenge sia ancora valida, che non sia stata già consumata, che la chiave pubblica appartenga all’account corretto e che il dispositivo non sia stato revocato. Se uno di questi controlli resta soltanto nel client, il flusso può essere aggirato.
Creare biometricHelper
Il primo passo è nascondere la libreria dietro un modulo applicativo. In questo modo il resto dell’app non dipende dai nomi specifici del pacchetto.
// src/helpers/biometricHelper.js
import ReactNativeBiometrics from 'react-native-biometrics';
const biometric = new ReactNativeBiometrics({
// Permette al sistema operativo di proporre anche PIN o passcode,
// quando la piattaforma e la configurazione lo consentono.
allowDeviceCredentials: true,
});
export async function getBiometricAvailability() {
return biometric.isSensorAvailable();
}
Il costruttore viene creato una sola volta. Non serve istanziare la libreria a ogni render della schermata e non è utile conservare nel codice JavaScript informazioni sul volto o sull’impronta.
Il risultato di isSensorAvailable() contiene normalmente un flag available e il tipo di biometria esposto dalla piattaforma. Su iOS si possono distinguere FaceID e TouchID; su Android il tipo può essere più generico, perché il sistema può supportare fingerprint, volto o altri autenticatori.
const availability = await getBiometricAvailability();
if (!availability.available) {
// Mostra il login standard o una spiegazione non bloccante.
return { ok: false, reason: availability.error };
}
return { ok: true, type: availability.biometryType };
Il controllo non deve essere usato per decidere se l’utente è autorizzato all’account. Dice soltanto se il dispositivo può eseguire quel tipo di operazione.
Creare e registrare le chiavi
Dopo un login iniziale riuscito — nel nostro caso, dopo la verifica dell’OTP — l’utente può scegliere di attivare la biometria. Il primo accesso non deve dipendere dalla chiave, perché la chiave ancora non esiste.
export async function registerBiometricKey() {
const availability = await biometric.isSensorAvailable();
if (!availability.available) {
return {
ok: false,
reason: availability.error || 'Biometria non disponibile',
};
}
const existing = await biometric.biometricKeysExist();
if (existing.keysExist) {
return { ok: true, alreadyRegistered: true };
}
const { publicKey } = await biometric.createKeys();
if (!publicKey) {
return { ok: false, reason: 'Chiave pubblica non generata' };
}
return { ok: true, publicKey, alreadyRegistered: false };
}
La chiave privata non viene restituita da createKeys(). È proprio questo il comportamento che vogliamo: il server riceve la chiave pubblica, mentre il materiale privato resta sotto il controllo del Keychain o del Keystore.
La registrazione lato app può quindi essere completata con una chiamata al backend:
export async function enableBiometricLogin(api) {
const result = await registerBiometricKey();
if (!result.ok || result.alreadyRegistered) {
return result;
}
await api.post('/biometric-keys', {
publicKey: result.publicKey,
});
return { ok: true };
}
In produzione è utile associare alla chiave un keyId restituito dal server, invece di usare soltanto la stringa della chiave pubblica come identificativo. Il backend deve inoltre poter revocare una singola chiave senza disattivare l’intero account.
Firmare una challenge durante l’auto-login
Quando l’app si apre e l’utente ha già attivato il login biometrico, non bisogna firmare un testo fisso. Il client chiede al server una challenge fresca e la passa a createSignature().
export async function authenticateWithBiometrics(api) {
const existing = await biometric.biometricKeysExist();
if (!existing.keysExist) {
return { ok: false, reason: 'Chiavi non presenti' };
}
const { data } = await api.get('/biometric/challenge');
const challenge = data?.challenge;
if (!challenge) {
return { ok: false, reason: 'Challenge non valida' };
}
const result = await biometric.createSignature({
promptMessage: 'Autorizza l’accesso a myGenius',
payload: challenge,
cancelButtonText: 'Annulla',
});
if (!result.success || !result.signature) {
return {
ok: false,
cancelled: !result.error,
reason: result.error || 'Autenticazione annullata',
};
}
const verification = await api.post('/biometric/verify', {
challenge,
signature: result.signature,
});
return {
ok: verification.data?.ok === true,
data: verification.data,
};
}
Il testo del prompt deve descrivere l’azione reale. È meglio «Autorizza l’accesso a myGenius» rispetto a una frase generica come «Conferma operazione». Il sistema operativo presenta il proprio dialogo, ma il motivo fornito dall’app contribuisce alla comprensione dell’utente.
Una cancellazione non è necessariamente un errore tecnico. L’utente può avere premuto “Annulla” oppure aver scelto il PIN. Il flusso deve tornare al login standard senza cancellare dati a caso e senza mostrare un messaggio allarmistico.
Il fallback su PIN e credenziali del dispositivo
Il fallback non dovrebbe essere implementato con un PIN inventato e memorizzato dall’app. La responsabilità deve restare al sistema operativo: PIN, passcode, pattern o password del dispositivo sono gestiti dalle API native.
Con allowDeviceCredentials: true, la libreria chiede alle piattaforme di consentire l’uso della credenziale del dispositivo secondo le loro regole. Il comportamento non è identico su iOS e Android e, nella documentazione del pacchetto, il supporto Android per questa opzione richiede API 30 o successive.
Per un’app che contiene dati HR, la policy va scelta in base al rischio:
- per leggere informazioni personali può essere accettabile biometria con fallback sul passcode del device;
- per approvare un’operazione irreversibile può essere preferibile chiedere un fattore aggiuntivo;
- dopo troppi tentativi falliti si deve tornare al login completo;
- se l’utente non ha configurato un blocco del dispositivo, l’app non dovrebbe fingere di offrire un livello di sicurezza equivalente.
Il fallback deve essere comunicato chiaramente. Se il prompt nativo permette il PIN, l’utente deve sapere che sta utilizzando la credenziale del dispositivo e non una seconda password dell’app.
Collegare il helper al login OTP
Nel progetto attuale il flusso OTP è orchestrato da src/features/identifier/useIdentifier.js. Dopo la verifica dell’email o dell’SMS, il backend restituisce un refresh_jwt; il codice mostra BiometricModal e, se l’utente accetta, salva il token nel Keychain.
La logica attuale è visibile in forma sintetica qui:
const handleEnableBiometric = async () => {
setBiometricModalVisible(false);
const biometryType = await Keychain.getSupportedBiometryType();
if (biometryType && pendingJWT) {
await saveJWT(pendingJWT, null);
const testJwt = await getJWT();
if (testJwt) {
await setBiometricLoginEnabled(true);
}
}
navigateToWebView();
};
Questo approccio protegge il refresh JWT nel Keychain e può essere una scelta pragmatica. Non è però lo stesso modello della firma di una challenge con una coppia di chiavi. Se l’obiettivo è passare alla soluzione basata su react-native-biometrics, il passaggio deve essere esplicito:
- completare il login OTP;
- mostrare il consenso applicativo attraverso
BiometricModal; - generare la coppia di chiavi;
- registrare la chiave pubblica sul backend;
- soltanto dopo una registrazione riuscita, considerare attiva la funzione;
- non salvare il refresh JWT come unica prova della biometria.
Il flag locale può ricordare che l’utente ha attivato la funzione, ma non deve essere considerato una prova di autenticazione. Un valore in AsyncStorage, come @biometric_login_enabled, è utile per la UX; chiunque riesca a modificarlo non deve poter ottenere accesso senza superare la verifica nativa e quella server-side.
Un BiometricModal che non forza la scelta
La modale di opt-in viene mostrata dopo il login, non prima. Nel progetto esistente src/components/auth/BiometricModal.js differenzia la resa tra iOS e Android, offre il pulsante «Attiva ora» e permette di scegliere «Non adesso».
La parte importante non è l’animazione, ma il contratto del componente:
<BiometricModal
visible={biometricModalVisible}
onEnable={handleEnableBiometric}
onClose={handleCloseModal}
/>
BiometricModal non deve importare ReactNativeBiometrics, chiamare endpoint o salvare token. Riceve due callback e resta un componente di presentazione. In questo modo si possono modificare testo, immagini e layout senza toccare la sicurezza del flusso.
Il testo dovrebbe spiegare almeno:
- che cosa verrà attivato;
- che l’app non riceve il volto o l’impronta;
- che la scelta è facoltativa;
- come accedere se la biometria non è disponibile.
Un buon fallback UX non è «attiva oppure resta bloccato». È «attiva ora» oppure «continua con il login standard».
Integrare l’auto-login
L’hook useAutoLogin viene eseguito all’avvio della navigazione e oggi legge il refresh JWT dal Keychain quando il flag biometrico è attivo. In un flusso basato su challenge, il percorso diventa concettualmente questo:
async function tryBiometricAutoLogin() {
const enabled = await isBiometricLoginEnabled();
if (!enabled) {
return { ok: false, reason: 'Funzione non attiva' };
}
try {
return await authenticateWithBiometrics(api);
} catch (error) {
return {
ok: false,
reason: 'Autenticazione non disponibile',
error,
};
}
}
La screen può usare il risultato senza conoscere i dettagli della firma:
const result = await tryBiometricAutoLogin();
if (result.ok) {
navigation.reset({
index: 0,
routes: [{ name: 'WebView' }],
});
} else {
// Mostra IdentifierScreen e conserva il percorso manuale.
}
Se la challenge scade, la chiave viene revocata o l’utente annulla il prompt, l’app deve restare utilizzabile con il login OTP. Se invece il server risponde che l’account è sospeso o la chiave è stata revocata per motivi di sicurezza, è opportuno pulire il riferimento locale e richiedere una nuova associazione.
Errori da gestire senza nasconderli
Un’integrazione biometrica affidabile non tratta tutti i fallimenti nello stesso modo.
Sensore assente o non configurato
Il dispositivo potrebbe non avere un sensore, oppure l’utente potrebbe non aver registrato alcun volto o impronta. In questo caso non bisogna mostrare una modale che l’utente non può completare.
Permesso negato
Su iOS l’utente può negare l’autorizzazione a Face ID. L’app deve consentire il login alternativo e, se serve, collegare l’utente alle impostazioni senza ripetere il prompt all’infinito.
Cancellazione del prompt
Annullare il dialogo è una scelta valida. Non bisogna cancellare automaticamente l’account né registrare un errore di sicurezza ogni volta che l’utente torna indietro.
Chiavi assenti
La disinstallazione dell’app, il reset del dispositivo o una migrazione non completata possono lasciare il server senza una chiave corrispondente. Il backend deve rifiutare la firma e l’app deve guidare l’utente attraverso una nuova associazione.
Cambio della configurazione biometrica
L’aggiunta o la rimozione di un’impronta può invalidare una chiave, in base alle policy native applicate. Anche qui il comportamento corretto è richiedere il login iniziale e registrare una nuova chiave, non aggirare il controllo.
Testare su dispositivi reali
Il simulatore non è sufficiente per validare questo flusso. La documentazione di react-native-biometrics segnala che il prompt usato per recuperare la chiave durante la firma non viene mostrato nello stesso modo sui simulatori iOS.
La matrice minima di test dovrebbe includere:
- iPhone con Face ID;
- iPhone o iPad con Touch ID, se supportato dal progetto;
- Android con fingerprint;
- Android con riconoscimento facciale, se disponibile;
- dispositivo senza biometria configurata;
- dispositivo con fallback su PIN;
- annullamento del prompt;
- cinque o più tentativi errati, secondo le regole della piattaforma;
- cambio dell’impronta o del volto registrato;
- reinstallazione dell’app;
- revoca della chiave lato server;
- challenge scaduta o già utilizzata;
- assenza temporanea di rete durante la verifica.
Per ogni scenario bisogna controllare sia la UI sia lo stato lato backend. Un test che verifica soltanto che appare il popup non dimostra che l’autenticazione abbia autorizzato correttamente l’account.
Checklist finale
Prima di considerare chiusa l’integrazione, verifico che:
- la screen non importi direttamente la libreria biometrica;
- la chiave privata non venga mai loggata o inviata;
- il server generi e consumi le challenge;
- il flag in
AsyncStoragenon sia trattato come prova di accesso; - il fallback al PIN appartenga al sistema operativo;
BiometricModalsia opzionale e non blocchi il login manuale;- logout e revoca siano gestiti anche lato backend;
- gli errori siano convertiti in messaggi comprensibili;
- il flusso funzioni anche senza rete e torni al percorso previsto;
- l’informativa privacy sia coerente con l’implementazione reale.
Conclusione
Integrare Face ID e Touch ID in React Native non significa aggiungere una funzione authenticate() e considerare il lavoro finito. La parte visibile — il prompt — è il punto più semplice. Le decisioni importanti riguardano la chiave privata, la challenge, la revoca, il fallback e il passaggio ordinato tra login manuale e auto-login.
Nel progetto myGenius esiste già un flusso pragmatico basato su react-native-keychain e refresh JWT protetto dal sistema operativo. react-native-biometrics introduce un modello più esplicito, basato su chiavi e firme server-side. Le due soluzioni non vanno confuse né migrate alla cieca: bisogna scegliere quale prova di autenticazione il backend vuole verificare e costruire intorno a quella scelta un adapter piccolo, testabile e sostituibile.




