Il problema: dove salvare il token JWT
C’è un momento, in ogni progetto React Native che implementa un login, in cui ci si scontra con la stessa domanda: il server ha appena restituito un token JWT, e adesso? Dove lo mettiamo, per evitare che l’utente debba reinserire email e password ogni volta che riapre l’app?
La risposta più gettonata nei tutorial è sempre la stessa: AsyncStorage. Ed è facile capire perché — è comodo, è asincrono, funziona identico su iOS e Android, e bastano tre righe per archiviare tutto:
await AsyncStorage.setItem('jwt_token', token);
const token = await AsyncStorage.getItem('jwt_token');
Il problema è che quelle tre righe nascondono un dettaglio tutt’altro che trascurabile: AsyncStorage non cifra nulla. Su Android il token finisce dentro un database SQLite leggibile in chiaro. Su iOS viene salvato in un file plist dentro la sandbox dell’app. Il che significa che chiunque abbia accesso fisico al dispositivo, o riesca a sfruttare una vulnerabilità di escalation, può aprire quel token come se fosse un file di testo qualunque.
Per un’app che gestisce una lista della spesa, probabilmente non cambia nulla. Ma per un’app aziendale che tocca presenze, ferie e dati dei dipendenti, questo genere di leggerezza smette di essere un dettaglio tecnico e diventa un rischio vero.
Perché AsyncStorage non è adatto a dati sensibili
Vale la pena essere chiari su questo punto, perché è spesso frainteso: AsyncStorage non è “insicuro” in senso assoluto, è semplicemente uno storage chiave-valore senza cifratura, e la stessa documentazione di React Native lo dice esplicitamente — non va usato per dati sensibili.
In pratica, questo si traduce in un paio di scenari concreti. Su Android i dati finiscono in RKStorage, un database SQLite che su un dispositivo rootato si legge senza dover decifrare nulla. Su iOS la situazione è leggermente diversa: i dati restano protetti dal sandboxing di sistema, ma non sono comunque cifrati a riposo, e un backup iTunes non cifrato — o un jailbreak — espone tutto senza troppa fatica.
Manca poi qualcosa che con dati sensibili sarebbe naturale aspettarsi: nessuna protezione biometrica. Non puoi chiedere Face ID o l’impronta prima di leggere un valore da AsyncStorage, semplicemente perché lo storage non è pensato per questo. E manca anche un meccanismo di pulizia automatica: se l’utente disinstalla l’app i dati spariscono, certo, ma se cambia dispositivo e ripristina un backup, quel token può ritrovarsi tranquillamente su un telefono diverso da quello originale.
Nulla di tutto questo rende AsyncStorage inutile, sia chiaro — resta perfetto per preferenze utente, flag booleani, dati di cache. Il problema nasce quando lo si usa per qualcosa che dovrebbe restare privato.
iOS Keychain vs Android Keystore: due mondi diversi
Le due piattaforme native offrono entrambe un sistema di archiviazione sicura, ma partono da filosofie molto diverse, ed è utile capire come funzionano prima di affidarsi a una libreria che le astrae.
Il Keychain di Apple è lo stesso gestore di credenziali che custodisce le password di Safari e i dati delle carte di credito, e porta con sé alcune garanzie di fabbrica: cifra automaticamente i dati con una chiave legata al dispositivo e al passcode, permette di richiedere Face ID o Touch ID prima della lettura, e offre un flag di accessibilità con cui decidere se un dato debba essere leggibile solo a dispositivo sbloccato, solo se è stato impostato un passcode, e così via. Con l’opzione giusta (THIS_DEVICE_ONLY), quel dato non finisce nemmeno nei backup iCloud.
Android Keystore segue una logica simile ma con un’implementazione più articolata. Sui dispositivi dotati di TEE (Trusted Execution Environment) o StrongBox, le chiavi vengono generate e usate direttamente in un hardware isolato, il che è ottimo dal punto di vista della sicurezza ma comporta anche più passaggi lato codice: bisogna generare una chiave, inizializzare un cipher, cifrare il dato manualmente. A complicare ulteriormente le cose c’è la frammentazione tipica di Android — non tutti i dispositivi supportano le stesse funzionalità, alcuni non hanno il TEE, altri ne hanno un’implementazione tutt’altro che perfetta.
Il risultato pratico è che scrivere codice nativo per gestire entrambe le piattaforme significa mantenere due implementazioni parallele, con logiche diverse, test diversi e casi limite diversi — che in un progetto React Native vanifica gran parte del vantaggio di scrivere codice una volta sola.
react-native-keychain: una API per entrambi
È qui che entra in gioco react-native-keychain, che si occupa di nascondere le differenze tra Keychain e Keystore dietro un’unica API JavaScript. L’installazione è quella di una libreria qualunque:
npm install react-native-keychain
con l’aggiunta, su iOS, di un passaggio in più dopo l’installazione:
cd ios && pod install
Da qui in poi, salvare un token diventa una questione di poche righe:
import * as Keychain from 'react-native-keychain';
async function saveToken(token) {
await Keychain.setInternetCredentials('auth_token', 'user', token);
}
Leggerlo è altrettanto immediato:
async function getToken() {
const credentials = await Keychain.getInternetCredentials('auth_token');
if (credentials) {
return credentials.password;
}
return null;
}
E cancellarlo, quando serve:
async function deleteToken() {
await Keychain.resetInternetCredentials('auth_token');
}
Dietro le quinte, la libreria traduce queste chiamate in operazioni native vere e proprie: su iOS parla direttamente con le API SecItem* del Keychain, su Android passa attraverso l’AndroidKeyStore cifrando con AES. La differenza che conta davvero, per chi scrive il codice, è che non deve occuparsi di cipher, IV o TEE — se ne occupa la libreria, e lo sviluppatore si limita a chiamare setInternetCredentials e a fidarsi.
Cosa salvare nel Keychain e cosa in AsyncStorage
Una domanda che torna spesso è: ok, il token va nel Keychain, ma tutto il resto? La risposta più onesta è: dipende da quanto è sensibile quel dato.
Nel progetto myGenius, per esempio, la linea è stata tracciata così: nel Keychain finiscono il token di autenticazione, il refresh JWT e il token Firebase usato per le notifiche push — tutte cose che, in mano sbagliata, permetterebbero di impersonare l’utente o accedere a dati privati. In AsyncStorage restano invece il flag di prima installazione, il device fingerprint e il flag che indica se la biometria è abilitata: dati che, se letti da un attaccante, non gli darebbero alcun vantaggio concreto.
Questo criterio si vede bene in un caso reale, preso dall’hook di auto-login dell’app:
// Flag non sensibile: AsyncStorage
const isFirstInstallation = await AsyncStorage.getItem('is_first_installation');
if (isFirstInstallation === null) {
// Pulizia di sicurezza alla prima installazione
await clearAllKeychain();
await AsyncStorage.setItem('is_first_installation', 'false');
}
// Token sensibile: Keychain
const jwt = await getJWT();
if (jwt) {
const success = await jwtLogin(jwt);
// ...
}
Il flag is_first_installation non vale nulla per un attaccante. Il JWT, al contrario, è letteralmente la chiave che apre l’account dell’utente — ed è questa asimmetria a decidere dove va salvato ciascun dato.
Accesso biometrico al token: un livello in più
C’è un vantaggio del Keychain che spesso passa in secondo piano rispetto alla cifratura, ma che nella pratica fa una grande differenza: la possibilità di chiedere un’autenticazione biometrica prima ancora di leggere il dato. Con AsyncStorage, semplicemente, questo non esiste.
react-native-keychain lo gestisce attraverso l’opzione accessControl. Su iOS, per esempio, si può richiedere biometria in questo modo:
// iOS: setGenericPassword con biometria
await Keychain.setGenericPassword('jwt_refresh', jwt, {
service: 'jwt_refresh_token',
accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_ANY_OR_DEVICE_PASSCODE,
accessible: Keychain.ACCESSIBLE.WHEN_PASSCODE_SET_THIS_DEVICE_ONLY,
authenticationPrompt: {
title: 'Usa accesso Biometrico',
subtitle: 'Usa Face ID o impronta per accedere a myGenius',
}
});
mentre su Android la stessa cosa passa da setInternetCredentials:
// Android: setInternetCredentials con biometria
await Keychain.setInternetCredentials('jwt_refresh_token', 'jwt_refresh', jwt, {
accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_CURRENT_SET,
authenticationPrompt: {
title: 'Autenticazione richiesta',
subtitle: "Usa l'impronta o il volto per confermare",
}
});
Quando l’app chiama getJWT(), è il sistema operativo stesso a mostrare il prompt di Face ID o dell’impronta, e solo dopo un’autenticazione riuscita il token viene effettivamente restituito. Se l’utente annulla il prompt, la funzione torna semplicemente null, senza esporre nulla.
Vale la pena notare che iOS e Android gestiscono questo access control in modo abbastanza diverso tra loro, ed è per questo che nel codice di myGenius le funzioni di salvataggio e lettura del JWT finiscono per usare API differenti a seconda della piattaforma: su iOS si passa da setGenericPassword / getGenericPassword con un service personalizzato, su Android da setInternetCredentials / getInternetCredentials con il server come chiave. Non è un limite della libreria, quanto piuttosto il riflesso di come i due sistemi operativi hanno costruito, ciascuno a modo suo, l’access control biometrico. react-native-keychain unifica il più possibile, ma qualche differenza di fondo resta inevitabile.
Errori comuni da evitare
Il primo errore, quello più classico, è salvare il JWT in AsyncStorage “per ora”, con l’idea di migrare più avanti al Keychain. Quel “più avanti” quasi sempre non arriva mai, e diventa debito tecnico permanente. Se l’app gestisce dati sensibili, la cosa più sensata è partire dal Keychain fin dal primo commit, non aggiungerlo dopo.
Un secondo errore riguarda la gestione del caso in cui il token non viene trovato: getInternetCredentials può restituire null o false a seconda della piattaforma e della versione della libreria, quindi il risultato va sempre controllato prima di usarlo:
const credentials = await Keychain.getInternetCredentials('auth_token');
if (credentials && credentials.password) {
return credentials.password;
}
return null;
Poi c’è la pulizia al logout, che è facile dimenticare parzialmente: al momento del logout vanno cancellati tutti i dati sensibili, non solo il token principale, ma anche eventuali token Firebase e i flag correlati:
export async function performLogout() {
await CookieManager.clearAll();
await deleteJWT();
await deleteAuthToken();
await AsyncStorage.removeItem('@biometric_login_enabled');
}
Un altro errore, più sottile, è usare lo stesso service name per token diversi: ogni voce nel Keychain ha bisogno di un identificatore univoco, e chiamare setInternetCredentials('token', ...) per due token differenti significa semplicemente sovrascrivere il primo con il secondo. Meglio usare nomi descrittivi come auth_token, jwt_refresh_token, firebase_token.
Infine, un errore che si paga caro solo al momento sbagliato: non testare il flusso biometrico su un dispositivo reale. Il prompt biometrico non funziona in modo affidabile sul simulatore iOS (a meno di configurare un Face ID enrollable) e va anche peggio sull’emulatore Android. Va sempre testato su hardware fisico.
Domande frequenti
AsyncStorage con cifratura manuale può andare bene?
Tecnicamente sì, ma resta una soluzione peggiore rispetto al Keychain. Bisognerebbe gestire da soli la generazione della chiave, l’IV, il cipher — e soprattutto capire dove conservare la chiave di cifratura stessa. Se finisce anche lei in AsyncStorage, il problema si è solo spostato di un passo, non risolto. Il Keychain, invece, si appoggia a chiavi hardware che non escono mai dal secure enclave o dal TEE.
react-native-keychain funziona con Expo?
Sì, ma solo con Expo Dev Client o EAS Build — non con Expo Go, perché richiede codice nativo. Chi lavora con Expo Go può orientarsi su expo-secure-store, che offre funzionalità simili.
Il token sparisce dopo un riavvio del dispositivo?
Dipende dal flag accessible scelto in fase di salvataggio. Con WHEN_PASSCODE_SET_THIS_DEVICE_ONLY il token sopravvive al riavvio ma resta legato a quel dispositivo. Con WHEN_UNLOCKED, invece, è leggibile solo quando il telefono è sbloccato. La scelta va fatta in base al caso d’uso specifico.
Cosa succede se l’utente disabilita la biometria?
Se si è usato BIOMETRY_ANY_OR_DEVICE_PASSCODE, il sistema fa semplicemente fallback sul passcode del dispositivo. Se invece si è usato BIOMETRY_CURRENT_SET e l’utente registra una nuova impronta, il token potrebbe diventare illeggibile. Per il refresh JWT, in myGenius si è scelto proprio BIOMETRY_ANY_OR_DEVICE_PASSCODE su iOS per evitare questo tipo di problema.
Conclusione
Salvare un JWT in AsyncStorage è un po’ come lasciare le chiavi di casa sotto lo zerbino: funziona, è comodo, ma chiunque sappia dove guardare le trova senza troppo sforzo.
Il Keychain su iOS e il Keystore su Android offrono qualcosa che AsyncStorage non potrà mai dare: cifratura hardware, protezione biometrica, isolamento reale. E react-native-keychain mette d’accordo le due piattaforme dietro un’unica API, permettendo di scrivere codice sicuro senza dover mantenere due implementazioni native separate.
Alla fine la regola si riduce a una sola frase: se un dato permette a qualcuno di impersonare l’utente, va nel Keychain. Tutto il resto può restare tranquillamente in AsyncStorage. Non è una questione di paranoia, ma di responsabilità — soprattutto quando l’app che si sta costruendo gestisce dati di dipendenti e presenze aziendali.
Nel prossimo articolo della serie, vediamo l’implementazione pratica: come creare il package auth con performLogin, performLogout e getAuthToken, e come integrare tutto con Redux per gestire lo stato di autenticazione nell’app.




