Quan una sola capa d’emmagatzematge es queda curta
La majoria d’apps React Native ho posen tot a AsyncStorage. Tokens, dades d’usuari, preferències, estat de sessió. Tot al mateix lloc, tot en text pla.
AsyncStorage és un magatzem clau-valor, basat en SQLite a Android i en fitxers en disc a iOS. És ràpid i pràctic. També està sense xifrar. Qualsevol que pugui arribar als fitxers de l’app, en un dispositiu rootejat o amb jailbreak o en un mòbil desbloquejat, pot llegir tots els valors.
Per a una preferència de tema, no passa res. Per a un access token, és un incident.
En aquest post recorro els tres nivells que faig servir en producció: el keystore de la plataforma per a tokens, un magatzem xifrat per a dades personals, i AsyncStorage (via Redux Persist) per a preferències. Cada nivell és un wrapper curt. La feina està a decidir què va on, i mantenir aquesta frontera honesta al flux d’autenticació.
Suposicions
Aquest recorregut dona per fet:
- React Native 0.74+ (bare workflow, no Expo)
- TypeScript amb la configuració estàndard de Babel de RN
- Redux Toolkit + Redux Persist per a la gestió d’estat
- iOS 13+ i Android API 23+ (el camí de codi del Keystore necessita API 23 com a mínim)
- Un backend Supabase (o qualsevol API REST que retorni tokens d’accés/refresc)
A Expo, canvia react-native-keychain per expo-secure-store al wrapper del Nivell 1. L’estructura és la mateixa.
Els tres nivells
| Nivell | Biblioteca | Seguretat | Velocitat | Usar per a |
|---|---|---|---|---|
| 1. SecureStore | react-native-keychain | Keystore del SO (Keychain/Keystore) | Més lent | Tokens, PINs amb hash |
| 2. EncryptedStore | react-native-encrypted-storage | Xifrat AES-256 | Mitjà | Dades personals (email, nom, telèfon) |
| 3. AsyncStorage | @react-native-async-storage | Cap (text pla) | Més ràpid | Preferències (tema, idioma) |
Cada nivell és un wrapper prim al voltant d’una biblioteca. El wrapper imposa claus tipades (perquè no puguis emmagatzemar un token al nivell equivocat) i proporciona una API coherent.
Nivell 1: SecureStore (Keychain / Keystore)
El nivell més alt. Usa l’emmagatzematge de claus propi de la plataforma: iOS Keychain o Android Keystore. El mateix sistema operatiu xifra les dades (amb suport de hardware a iOS; a Android depèn del dispositiu, mira la nota de més endavant) i pot requerir autenticació biomètrica per llegir-les.
yarn add react-native-keychain@10.0.0
cd ios && pod install && cd ..
react-native-keychain és un mòdul natiu, així que iOS necessita un pod install. A Android, posa minSdkVersion = 23 (o més alt) a android/build.gradle per arribar al camí de codi del Keystore.
Una nota sobre Android: fins i tot a API 23+, que les claus realment vagin a un Trusted Execution Environment o a un StrongBox depèn del dispositiu i de l’OEM. Alguns mòbils moderns continuen declarant emmagatzematge només per software. Si el teu model d’amenaces necessita una garantia, crida Keychain.getSecurityLevel() en temps d’execució i condiciona les operacions sensibles al resultat. iOS Keychain té suport de hardware a tots els dispositius compatibles.
El wrapper:
// src/utils/storage/SecureStore.ts
import * as Keychain from 'react-native-keychain';
export enum SecureStoreKey {
ACCESS_TOKEN = 'accessToken',
REFRESH_TOKEN = 'refreshToken',
USER_ID = 'userId',
HASHED_PIN = 'hashedPIN',
}
const SERVICE_PREFIX = 'com.warrendeleon.portfolio';
// Claus que demanen reautenticació per llegir-se. Els tokens queden sense
// barrera perquè l'interceptor de refresh i el check de sessió en arrencar
// en fred no disparin mai cap prompt.
const BIOMETRIC_GATED: SecureStoreKey[] = [SecureStoreKey.HASHED_PIN];
export const SecureStore = {
async set(key: SecureStoreKey, value: string): Promise<boolean> {
await Keychain.setGenericPassword(key, value, {
service: `${SERVICE_PREFIX}.${key}`,
...(BIOMETRIC_GATED.includes(key) && {
accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_ANY_OR_DEVICE_PASSCODE,
}),
accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
});
return true;
},
async get(key: SecureStoreKey): Promise<string | null> {
const result = await Keychain.getGenericPassword({
service: `${SERVICE_PREFIX}.${key}`,
});
return result ? result.password : null;
},
async remove(key: SecureStoreKey): Promise<boolean> {
await Keychain.resetGenericPassword({
service: `${SERVICE_PREFIX}.${key}`,
});
return true;
},
async clear(): Promise<boolean> {
for (const key of Object.values(SecureStoreKey)) {
await Keychain.resetGenericPassword({
service: `${SERVICE_PREFIX}.${key}`,
});
}
return true;
},
};
Val la pena destacar quatre decisions del wrapper:
- Un servei per clau. Keychain emmagatzema una sola credencial per identificador de servei. Usar
com.warrendeleon.portfolio.accessTokenicom.warrendeleon.portfolio.refreshTokencom a serveis separats evita que se sobreescriguin mútuament. - La protecció biomètrica és per clau, no general.
BIOMETRY_ANY_OR_DEVICE_PASSCODEvol dir que llegir el valor pot fer aparèixer un prompt de Face ID / Touch ID / codi, en el moment en què passi la lectura. Protegeix amb biometria les claus que un humà hauria de desbloquejar conscientment (el PIN amb hash) i deixa els tokens sense, o el teu refresh de token en segon pla i el check de sessió en arrencar en fred mostraran un prompt a l’usuari en moments que semblen bugs. - Només aquest dispositiu.
WHEN_UNLOCKED_THIS_DEVICE_ONLYmanté les dades fora de les còpies de seguretat d’iCloud Keychain. Els tokens no han de sortir del dispositiu. - Claus amb enum tipat. No pots passar una string per accident. El compilador assegura que només dades de nivell token van a SecureStore.
Nivell 2: EncryptedStore (AES-256)
El nivell intermedi. Les dades es xifren amb AES-256, sense barrera de hardware, sense prompt biomètric. Més ràpid que Keychain, molt més segur que el text pla.
yarn add react-native-encrypted-storage@4.0.3
cd ios && pod install && cd ..
El wrapper:
// src/utils/storage/EncryptedStore.ts
import EncryptedStorage from 'react-native-encrypted-storage';
export enum EncryptedStoreKey {
USER_EMAIL = 'userEmail',
USER_FIRST_NAME = 'userFirstName',
USER_LAST_NAME = 'userLastName',
USER_PHONE_NUMBER = 'userPhoneNumber',
PROFILE_PICTURE_URL = 'profilePictureURL',
AUTH_PROVIDER = 'authProvider',
}
export const EncryptedStore = {
async set(key: EncryptedStoreKey, value: string): Promise<boolean> {
await EncryptedStorage.setItem(key, value);
return true;
},
async get(key: EncryptedStoreKey): Promise<string | null> {
return await EncryptedStorage.getItem(key);
},
async remove(key: EncryptedStoreKey): Promise<boolean> {
await EncryptedStorage.removeItem(key);
return true;
},
async setMultiple(
items: { key: EncryptedStoreKey; value: string }[]
): Promise<boolean> {
for (const item of items) {
await EncryptedStorage.setItem(item.key, item.value);
}
return true;
},
async getMultiple(
keys: EncryptedStoreKey[]
): Promise<Record<string, string | null>> {
const result: Record<string, string | null> = {};
for (const key of keys) {
result[key] = await EncryptedStorage.getItem(key);
}
return result;
},
async clear(): Promise<boolean> {
await EncryptedStorage.clear();
return true;
},
};
Per què no posar les dades personals a SecureStore? Rendiment. L’accés a Keychain executa una comprovació de seguretat a nivell de sistema, i a vegades un prompt biomètric. Per renderitzar el nom d’un usuari en una pantalla de perfil, aquesta sobrecàrrega no surt a compte. EncryptedStore et dona AES-256 en repòs sense la barrera de hardware, i la biblioteca gestiona el seu propi material de claus: tu no generes ni guardes mai cap clau de xifratge.
Les operacions per lots (setMultiple, getMultiple) importen en fluxos d’autenticació que necessiten escriure uns quants camps alhora:
await EncryptedStore.setMultiple([
{ key: EncryptedStoreKey.USER_EMAIL, value: user.email },
{ key: EncryptedStoreKey.USER_FIRST_NAME, value: user.firstName },
{ key: EncryptedStoreKey.USER_LAST_NAME, value: user.lastName },
]);
Nivell 3: AsyncStorage + Redux Persist
El nivell més ràpid. Text pla, sense xifrat. Reservat per a dades sense pes de seguretat: preferència de tema, selecció d’idioma.
yarn add @react-native-async-storage/async-storage@3.1.1 redux-persist@6.0.0 @reduxjs/toolkit@2.12.0 react-redux@9.3.0
cd ios && pod install && cd ..
Les versions fixades són aquelles contra les quals es va validar aquest setup, al tag blog-2026-08 del repo.
No parles amb AsyncStorage directament per a preferències. Redux Persist ho fa per tu. Guarda l’estat de Redux a AsyncStorage i el rehidrata a l’inici de l’app.
La configuració de persistència és on viu la frontera de seguretat:
// src/store/configureStore.ts
import AsyncStorage from '@react-native-async-storage/async-storage';
import { combineReducers, configureStore } from '@reduxjs/toolkit';
import { persistReducer, persistStore, FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER } from 'redux-persist';
// Els reducers venen del submòdul store de cada feature, no del seu barrel
// públic. El barrel també exporta pantalles, i les pantalles importen
// l'store, així que importar-lo aquí tanca un cicle de require que deixa un
// reducer undefined.
import { authReducer } from '@app/features/Auth/store';
import { settingsReducer } from '@app/features/Settings/store';
// L'slice d'auth té el seu propi persist config per poder posar un sol camp a la whitelist.
const authPersistConfig = {
key: 'auth',
storage: AsyncStorage,
whitelist: ['biometricEnabled'],
};
const persistedAuthReducer = persistReducer(authPersistConfig, authReducer);
const rootReducer = combineReducers({
settings: settingsReducer,
auth: persistedAuthReducer,
});
// El persist config arrel només persisteix l'slice de settings (tema, idioma).
const rootPersistConfig = {
key: 'root',
storage: AsyncStorage,
whitelist: ['settings'],
};
const persistedReducer = persistReducer(rootPersistConfig, rootReducer);
export const store = configureStore({
reducer: persistedReducer,
middleware: getDefaultMiddleware =>
getDefaultMiddleware({
serializableCheck: {
// Redux Persist despatxa accions no serialitzables durant la rehidratació.
// Ignora-les perquè el middleware de serializable-check no avisi.
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
},
}),
});
export const persistor = persistStore(store);
| Config | Què persisteix | Què exclou |
|---|---|---|
rootPersistConfig | Només l’slice de settings (tema, idioma) | Tota la resta |
authPersistConfig | Només el flag biometricEnabled | user, error, isLoading, tokens |
La whitelist és la peça que aguanta el pes. És una llista positiva: només els slices que anomenes es persisteixen, tota la resta és efímera. Així és com evites que els tokens acabin a AsyncStorage a través de Redux.
const settingsSlice = createSlice({
name: 'settings',
initialState: {
theme: 'system' as 'light' | 'dark' | 'system',
language: 'en' as string,
},
reducers: {
setTheme: (state, action) => { state.theme = action.payload; },
setLanguage: (state, action) => { state.language = action.payload; },
},
});
Quan l’usuari canvia el tema o l’idioma, Redux Persist escriu a AsyncStorage per tu. Al proper inici, PersistGate espera la rehidratació abans de renderitzar:
// App.tsx
import { Provider } from 'react-redux';
import { PersistGate } from 'redux-persist/integration/react';
import { persistor, store } from '@app/store/configureStore';
export default function App() {
return (
<Provider store={store}>
<PersistGate loading={null} persistor={persistor}>
{/* les teves pantalles */}
</PersistGate>
</Provider>
);
}
PersistGate bloqueja el render fins que l’slice persistit s’ha tornat a carregar a l’store. Sense ell, l’app mostra l’estat per defecte un frame abans que el tema/idioma persistit prengui el relleu.
Com es componen els nivells en un flux d’autenticació
Els tres nivells treballen junts al login, la restauració de sessió, el logout i el refresc de tokens.
Inici de sessió
// 1. El backend retorna tokens i dades d'usuari
const { access_token, refresh_token, user } = await authClient.signIn(credentials);
// 2. Tokens → SecureStore (Nivell 1)
await SecureStore.set(SecureStoreKey.ACCESS_TOKEN, access_token);
await SecureStore.set(SecureStoreKey.REFRESH_TOKEN, refresh_token);
await SecureStore.set(SecureStoreKey.USER_ID, user.id);
// 3. Dades personals → EncryptedStore (Nivell 2)
await EncryptedStore.set(EncryptedStoreKey.USER_EMAIL, user.email);
await EncryptedStore.set(EncryptedStoreKey.USER_FIRST_NAME, user.firstName);
// 4. Estat de Redux actualitzat → la UI renderitza
dispatch(setUser(user));
// Els settings (tema, idioma) ja són a Redux via Persist (Nivell 3)
Inici de l’app (restauració de sessió)
export const checkSession = createAsyncThunk(
'auth/checkSession',
async () => {
// Comprovar si tenim un token vàlid (Nivell 1)
const accessToken = await SecureStore.get(SecureStoreKey.ACCESS_TOKEN);
if (!accessToken) return null;
// Restaurar dades d'usuari (Nivell 2)
const email = await EncryptedStore.get(EncryptedStoreKey.USER_EMAIL);
const firstName = await EncryptedStore.get(EncryptedStoreKey.USER_FIRST_NAME);
const userId = await SecureStore.get(SecureStoreKey.USER_ID);
// Els settings ja han estat restaurats per PersistGate (Nivell 3)
return { id: userId, email, firstName };
}
);
Tancament de sessió
// 1. Invalidar el refresh token al backend
await authClient.logout();
// 2. Netejar tokens (Nivell 1)
await SecureStore.clear();
// 3. Netejar dades personals (Nivell 2)
await EncryptedStore.clear();
// 4. Netejar l'estat d'auth de Redux
dispatch(resetAuth());
// Els settings (Nivell 3) sobreviuen al logout. L'usuari conserva el tema i l'idioma.
La seqüència de tancament de sessió és deliberada. El Nivell 1 i el Nivell 2 es netegen perquè els tokens i les dades personals pertanyen a la sessió. El Nivell 3 es queda perquè el tema i l’idioma pertanyen al dispositiu.
Renovació de token
L’interceptor d’Axios gestiona la renovació de tokens en segon pla. Llegeix i escriu a SecureStore sense tocar els altres nivells:
import axios from 'axios';
import Config from 'react-native-config';
axiosInstance.interceptors.response.use(
response => response,
async error => {
// Reintenta només un cop per petició, o un refresh que segueix donant 401 entra en bucle infinit.
if (error.response?.status === 401 && !error.config._retry) {
error.config._retry = true;
try {
const refreshToken = await SecureStore.get(SecureStoreKey.REFRESH_TOKEN);
// El GoTrue de Supabase vol grant_type com a paràmetre de query, i una
// crida axios a pèl necessita la URL absoluta (aquí no hi ha baseURL).
const { data } = await axios.post(
`${Config.SUPABASE_URL}/auth/v1/token?grant_type=refresh_token`,
{ refresh_token: refreshToken },
{ headers: { apikey: Config.SUPABASE_ANON_KEY } }
);
// Actualitzar tokens a SecureStore
await SecureStore.set(SecureStoreKey.ACCESS_TOKEN, data.access_token);
await SecureStore.set(SecureStoreKey.REFRESH_TOKEN, data.refresh_token);
// Reintentar la petició original
error.config.headers.Authorization = `Bearer ${data.access_token}`;
return axiosInstance(error.config);
} catch (refreshError) {
// No hi ha refresh token, o ha expirat: la sessió s'ha acabat. Neteja el
// nivell segur perquè l'app torni al flux de login.
await SecureStore.clear();
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
El flag _retry evita que un refresc fallit entri en bucle per sempre, i un refresc fallit neteja el nivell segur perquè l’app torni al flux de login. Un cas que no cobreix: diverses peticions que reben un 401 alhora disparen cadascuna el seu propi refresc. Col·lapsar-les en un únic refresc en curs és un problema a part, i mereix el seu propi post.
La classificació de dades
Cada dada emmagatzemada té un lloc clar:
| Dada | Nivell | Per què |
|---|---|---|
| Access token | 1 (SecureStore) | Dona accés a l’API. Protecció del keystore. |
| Refresh token | 1 (SecureStore) | Pot generar nous access tokens. L’objectiu de més valor. |
| ID d’usuari | 1 (SecureStore) | S’usa per identificar l’usuari a les peticions. |
| PIN hashejat | 1 (SecureStore) | Credencial d’autenticació local. |
| 2 (EncryptedStore) | Dada personal. Xifrada però necessita accés ràpid per mostrar-la. | |
| Nom | 2 (EncryptedStore) | Dada personal. Es mostra a pantalles de perfil. |
| Telèfon | 2 (EncryptedStore) | Dada personal. Es mostra a configuració. |
| Proveïdor d’auth | 2 (EncryptedStore) | No és sensible però està relacionat amb la sessió d’auth. |
| Preferència biomètrica | 3 (AsyncStorage via Redux Persist) | Una preferència, no un secret. Regula la UX, no l’accés. |
| Tema | 3 (AsyncStorage) | Preferència no sensible. Sobreviu al tancament de sessió. |
| Idioma | 3 (AsyncStorage) | Preferència no sensible. Sobreviu al tancament de sessió. |
La regla és curta: si dona accés, Nivell 1. Si identifica una persona, Nivell 2. Si és una preferència, Nivell 3. Aquesta classificació també dona forma a l’estructura del projecte. Els wrappers d’emmagatzematge viuen en una carpeta compartida utils/storage/, i el flux d’autenticació que els orquestra viu dins de la feature Auth.
Errors freqüents
No oblidis que els valors del Keychain d’iOS sobreviuen a una desinstal·lació. Esborra l’app, reinstal·la-la, i el Nivell 1 encara guarda els tokens antics mentre que el Nivell 2 i el Nivell 3 tornen buits. El teu check de sessió llavors restaura un login que l’usuari creia haver esborrat, sense cap dada de perfil al darrere. Detecta la primera execució (un sentinella a AsyncStorage funciona, perquè AsyncStorage sí que s’esborra) i neteja SecureStore abans de llegir-ne res.
No emmagatzemis tokens a Redux. L’estat de Redux pot ser serialitzat, registrat, persistit a AsyncStorage via Redux Persist, i inspeccionat amb DevTools. Fins i tot amb l’slice d’auth a la blacklist, una sola mala configuració exposa els tokens. Guarda els tokens a SecureStore, i punt.
No saltis els enums tipats. Sense SecureStoreKey i EncryptedStoreKey, estàs passant strings a pèl. Una errada de tecleig i llegeixes de la clau equivocada. Un nivell equivocat i emmagatzemes un token en text pla. El sistema de tipus és l’auditoria de seguretat més barata que faràs mai.
No oblidis netejar en tancar sessió. Si neteges SecureStore però et saltes EncryptedStore, les dades personals de l’usuari encara hi són després del logout. El mètode clear() de cada nivell és el contracte: crida els dos nivells segurs durant el tancament de sessió.
No donis per fet que Keychain és ràpid. SecureStore fa una anada i tornada a l’enclavament segur, i en dispositius antics una lectura és prou lenta per notar-se. No el cridis dins un bucle de renderització. Llegeix els tokens un cop a l’inici de l’app i passa’ls a través del teu interceptor HTTP.
Usa la whitelist de Redux Persist, no la blacklist. Anomena què ha de persistir. La blacklist és arriscada perquè els slices nous persisteixen per defecte. Un sol slice nou amb dades sensibles i tens una filtració. whitelist és opt-in, i més segura.
Llavors, per què tres biblioteques
Una biblioteca (AsyncStorage) deixa els tokens en text pla. Una biblioteca (react-native-keychain) és massa lenta per a lectures no sensibles. Tres biblioteques, tres wrappers, tres enums, cada wrapper un únic fitxer petit.
El resultat: tokens guardats sota clau al keystore de la plataforma i fora de les còpies al núvol, un PIN que no es pot llegir sense biometria o el codi del dispositiu, dades personals xifrades en repòs, i preferències que es carreguen al primer frame. Cada dada està protegida exactament al nivell que realment necessita.
Els exemples de codi d’aquest post són de rn-warrendeleon, el meu projecte personal de React Native; el tag blog-2026-08 marca l’estat exacte al qual corresponen. Les configuracions completes de SecureStore, EncryptedStore i Redux Persist són al repo.