Cuando un solo almacén se queda corto
La mayoría de las apps React Native guardan todo en AsyncStorage. Tokens, datos del usuario, preferencias, estado de sesión. Todo en un mismo sitio, todo en texto plano.
AsyncStorage es un key-value store, respaldado por SQLite en Android y archivos en disco en iOS. Es rápido y cómodo. También está sin cifrar. Cualquiera que pueda llegar a los archivos de la app, en un dispositivo rooteado o con jailbreak o en un móvil desbloqueado, puede leer cada valor.
Para una preferencia de tema, no pasa nada. Para un access token, es un incidente.
Este post recorre los tres niveles que uso en producción: el keystore de la plataforma para tokens, un almacén cifrado para datos personales y AsyncStorage (vía Redux Persist) para preferencias. Cada nivel es un wrapper fino. El trabajo está en decidir qué vive dónde, y luego hacer que esa frontera se respete dentro de tu flujo de auth.
Supuestos
Este recorrido se escribió para:
- React Native 0.74+ (bare workflow, no Expo)
- TypeScript con la config de Babel estándar de RN
- Redux Toolkit + Redux Persist para la gestión de estado
- iOS 13+ y Android API 23+ (el code path del Keystore necesita API 23 como mínimo)
- Un backend de Supabase (o cualquier API REST que devuelva access/refresh tokens)
En Expo, cambia react-native-keychain por expo-secure-store en el wrapper del Nivel 1. La estructura se mantiene igual.
Los tres niveles
| Nivel | Librería | Seguridad | Velocidad | Usa para |
|---|---|---|---|---|
| 1. SecureStore | react-native-keychain | Keystore del SO (Keychain/Keystore) | Más lento | Tokens, PINs hasheados |
| 2. EncryptedStore | react-native-encrypted-storage | Cifrado AES-256 | Medio | Datos personales (email, nombre, teléfono) |
| 3. AsyncStorage | @react-native-async-storage | Ninguna (texto plano) | Más rápido | Preferencias (tema, idioma) |
Cada nivel es un wrapper fino sobre una librería. El wrapper obliga a usar claves tipadas (así no puedes guardar un token en el nivel equivocado) y proporciona una API consistente.
Nivel 1: SecureStore (Keychain / Keystore)
El nivel más alto. Usa el almacenamiento de claves propio de la plataforma: iOS Keychain o Android Keystore. El propio sistema operativo cifra los datos (con respaldo de hardware en iOS; en Android depende del dispositivo, ver la nota de más adelante) y se puede exigir autenticación biométrica para leerlos.
yarn add react-native-keychain@10.0.0
cd ios && pod install && cd ..
react-native-keychain es un módulo nativo, así que iOS necesita un pod install. En Android, pon minSdkVersion = 23 (o más alto) en android/build.gradle para llegar al code path del Keystore.
Una nota sobre Android: incluso en API 23+, que las claves acaben en un Trusted Execution Environment o un StrongBox depende del dispositivo y del OEM. Algunos móviles modernos siguen declarando almacenamiento solo por software. Si tu modelo de amenazas necesita una garantía, llama a Keychain.getSecurityLevel() en runtime y condiciona las operaciones sensibles al resultado. iOS Keychain tiene respaldo de hardware en todos los dispositivos 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';
// Claves que exigen reautenticación para leerse. Los tokens quedan
// sin barrera para que el interceptor de refresh y la comprobación de sesión en el
// arranque en frío nunca disparen un 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;
},
};
En ese wrapper hay cuatro decisiones que conviene señalar:
- Un service por clave. Keychain almacena una sola credencial por identificador de servicio. Usar
com.warrendeleon.portfolio.accessTokenycom.warrendeleon.portfolio.refreshTokencomo servicios separados evita que se sobrescriban entre sí. - La barrera biométrica es por clave, no general.
BIOMETRY_ANY_OR_DEVICE_PASSCODEsignifica que leer el valor puede hacer aparecer un prompt de Face ID / Touch ID / passcode, en el momento exacto en que ocurra la lectura. Ponle la barrera a las claves que un humano debería desbloquear conscientemente (el PIN hasheado) y deja los tokens sin barrera, o tu refresh de token en segundo plano y tu comprobación de sesión en el arranque en frío acabarán mostrando prompts al usuario en momentos que parecen bugs. - Solo este dispositivo.
WHEN_UNLOCKED_THIS_DEVICE_ONLYmantiene los datos fuera de los backups de iCloud Keychain. Los tokens no deberían salir del dispositivo. - Claves tipadas con enum. No puedes pasar un string por accidente. El compilador obliga a que al SecureStore solo lleguen datos del nivel de los tokens.
Nivel 2: EncryptedStore (AES-256)
El nivel intermedio. Los datos se cifran con AES-256, sin barrera de hardware ni prompt biométrico. Más rápido que Keychain, mucho más seguro que texto plano.
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;
},
};
¿Por qué no poner los datos personales en SecureStore? Rendimiento. El acceso a Keychain hace una verificación de seguridad a nivel de sistema y, a veces, un prompt biométrico. Para mostrar el nombre de un usuario en una pantalla de perfil, esa sobrecarga no compensa. EncryptedStore te da cifrado AES-256 en reposo sin la barrera de hardware, y la librería gestiona su propio material de claves: tú nunca generas ni guardas una clave de cifrado.
Las operaciones batch (setMultiple, getMultiple) importan para los flujos de auth que necesitan escribir varios campos a la vez:
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 },
]);
Nivel 3: AsyncStorage + Redux Persist
El nivel más rápido. Texto plano, sin cifrado. Reservado para datos sin peso de seguridad: preferencia de tema, selección de 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 ..
Estas son las versiones contra las que se validó este setup, en el tag blog-2026-08 del repo.
No hablas con AsyncStorage directamente para las preferencias. Lo hace Redux Persist por ti. Guarda tu estado de Redux en AsyncStorage y lo rehidrata al arrancar la app.
La configuración de persist es donde vive la frontera de seguridad:
// 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';
// Los reducers vienen del submódulo store de cada feature, no de su barrel
// público. El barrel también exporta pantallas, y las pantallas importan el
// store, así que importarlo aquí cierra un ciclo de require que deja un
// reducer undefined.
import { authReducer } from '@app/features/Auth/store';
import { settingsReducer } from '@app/features/Settings/store';
// El slice de auth tiene su propio persist config para incluir un solo campo en 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 raíz solo persiste el 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 despacha acciones no serializables durante la rehidratación.
// Ignóralas para que el middleware de serializable-check no avise.
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
},
}),
});
export const persistor = persistStore(store);
| Config | Qué persiste | Qué excluye |
|---|---|---|
rootPersistConfig | Solo el slice de settings (tema, idioma) | Todo lo demás |
authPersistConfig | Solo el flag biometricEnabled | user, error, isLoading, tokens |
La whitelist es la pieza que sostiene todo. Es una lista positiva: solo los slices que nombras se persisten, el resto es efímero. Así evitas que los tokens acaben en 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; },
},
});
Cuando el usuario cambia el tema o el idioma, Redux Persist se encarga de escribir en AsyncStorage. En el próximo arranque, PersistGate espera la rehidratación antes de renderizar:
// 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}>
{/* tus pantallas */}
</PersistGate>
</Provider>
);
}
PersistGate bloquea el render hasta que el slice persistido se ha vuelto a cargar en el store. Sin él, la app muestra el estado por defecto durante un frame antes de que el tema/idioma persistido tome el relevo.
Cómo se componen los niveles en un flujo de auth
Los tres niveles trabajan juntos a lo largo de login, restauración de sesión, logout y renovación de token.
Login
// 1. El backend devuelve tokens y datos del usuario
const { access_token, refresh_token, user } = await authClient.signIn(credentials);
// 2. Tokens → SecureStore (Nivel 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. Datos personales → EncryptedStore (Nivel 2)
await EncryptedStore.set(EncryptedStoreKey.USER_EMAIL, user.email);
await EncryptedStore.set(EncryptedStoreKey.USER_FIRST_NAME, user.firstName);
// 4. Se actualiza el estado de Redux → la UI renderiza
dispatch(setUser(user));
// Los settings (tema, idioma) ya están en Redux vía Persist (Nivel 3)
Arranque de la app (restauración de sesión)
export const checkSession = createAsyncThunk(
'auth/checkSession',
async () => {
// Verificar si tenemos un token válido (Nivel 1)
const accessToken = await SecureStore.get(SecureStoreKey.ACCESS_TOKEN);
if (!accessToken) return null;
// Restaurar datos del usuario (Nivel 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);
// Los settings ya fueron restaurados por PersistGate (Nivel 3)
return { id: userId, email, firstName };
}
);
Logout
// 1. Invalidar el refresh token en el backend
await authClient.logout();
// 2. Limpiar tokens (Nivel 1)
await SecureStore.clear();
// 3. Limpiar datos personales (Nivel 2)
await EncryptedStore.clear();
// 4. Limpiar el estado de auth en Redux
dispatch(resetAuth());
// Los settings (Nivel 3) persisten después del logout. El usuario conserva su tema e idioma.
La secuencia de logout es deliberada. Los Niveles 1 y 2 se limpian porque los tokens y los datos personales pertenecen a la sesión. El Nivel 3 se queda porque el tema y el idioma pertenecen al dispositivo.
Renovación de token
El interceptor de Axios se encarga de la renovación de tokens en segundo plano. Lee y escribe en SecureStore sin tocar los otros niveles:
import axios from 'axios';
import Config from 'react-native-config';
axiosInstance.interceptors.response.use(
response => response,
async error => {
// Reintenta solo una vez por petición, o un refresh que sigue dando 401 entra en bucle infinito.
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 quiere grant_type como parámetro de query, y una
// llamada a axios a pelo necesita la URL absoluta (aquí no hay 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 } }
);
// Actualizar tokens en SecureStore
await SecureStore.set(SecureStoreKey.ACCESS_TOKEN, data.access_token);
await SecureStore.set(SecureStoreKey.REFRESH_TOKEN, data.refresh_token);
// Reintentar la petición original
error.config.headers.Authorization = `Bearer ${data.access_token}`;
return axiosInstance(error.config);
} catch (refreshError) {
// No hay refresh token, o ha expirado: la sesión ha terminado. Limpia el
// nivel seguro para que la app vuelva al flujo de login.
await SecureStore.clear();
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
El flag _retry evita que un refresh fallido entre en bucle para siempre, y un refresh fallido limpia el nivel seguro para que la app vuelva al flujo de login. Un caso que no cubre: varias peticiones que reciben un 401 a la vez disparan cada una su propio refresh. Agruparlas en un único refresh en vuelo es un problema aparte, y merece su propio post.
La clasificación de datos
Cada dato almacenado tiene un lugar claro:
| Dato | Nivel | Por qué |
|---|---|---|
| Access token | 1 (SecureStore) | Da acceso a la API. Protección del keystore. |
| Refresh token | 1 (SecureStore) | Puede generar nuevos access tokens. El objetivo de mayor valor. |
| User ID | 1 (SecureStore) | Se usa para identificar al usuario en cada petición. |
| PIN hasheado | 1 (SecureStore) | Credencial de autenticación local. |
| 2 (EncryptedStore) | Dato personal. Cifrado pero necesita acceso rápido para mostrarse. | |
| Nombre | 2 (EncryptedStore) | Dato personal. Se muestra en pantallas de perfil. |
| Teléfono | 2 (EncryptedStore) | Dato personal. Se muestra en configuración. |
| Proveedor de auth | 2 (EncryptedStore) | No es sensible pero está relacionado con la sesión de auth. |
| Preferencia biométrica | 3 (AsyncStorage vía Redux Persist) | Una preferencia, no un secreto. Regula la UX, no el acceso. |
| Tema | 3 (AsyncStorage) | Preferencia no sensible. Sobrevive al logout. |
| Idioma | 3 (AsyncStorage) | Preferencia no sensible. Sobrevive al logout. |
La regla es corta: si da acceso, Nivel 1. Si identifica a una persona, Nivel 2. Si es una preferencia, Nivel 3. La clasificación también moldea cómo organizas el proyecto. Los wrappers de almacenamiento van en una carpeta compartida utils/storage/, y el flujo de auth que los orquesta vive dentro de la feature Auth.
Errores comunes
No olvides que los valores del Keychain de iOS sobreviven a una desinstalación. Borra la app, reinstálala, y el Nivel 1 sigue guardando los tokens viejos mientras que el Nivel 2 y el Nivel 3 vuelven a estar vacíos. Tu comprobación de sesión restaura entonces una sesión que el usuario creía haber cerrado, sin datos de perfil detrás. Detecta la primera ejecución (un centinela en AsyncStorage funciona, porque AsyncStorage sí se borra) y limpia SecureStore antes de leer nada de él.
No guardes tokens en Redux. El estado de Redux se puede serializar, registrar en logs, persistir en AsyncStorage vía Redux Persist e inspeccionar con DevTools. Aunque pongas el slice de auth en blacklist, una sola mala configuración expone los tokens. Guarda los tokens en SecureStore, punto.
No te saltes los enums tipados. Sin SecureStoreKey y EncryptedStoreKey estás pasando strings sueltos. Un typo y lees de la clave equivocada. Un nivel equivocado y guardas un token en texto plano. El sistema de tipos es la auditoría de seguridad más barata que vas a tener.
No te olvides de limpiar en el logout. Limpias SecureStore pero te saltas EncryptedStore y los datos personales del usuario se quedan después del logout. El método clear() de cada nivel es el contrato: llama a los dos niveles seguros durante el logout.
No des por supuesto que Keychain es rápido. SecureStore hace un round trip al enclave seguro, y en dispositivos antiguos una lectura es lo bastante lenta como para notarse. No lo llames en un render loop. Lee los tokens una vez al arrancar y pásalos a través de tu interceptor HTTP.
Usa whitelist en Redux Persist, no blacklist. Nombra lo que debe persistir. blacklist es peligroso porque los slices nuevos se persisten por defecto. Un slice nuevo con datos sensibles y tienes un leak. whitelist es opt-in, y más segura.
Entonces, ¿por qué tres librerías?
Una librería (AsyncStorage) deja los tokens en texto plano. Una librería (react-native-keychain) es demasiado lenta para lecturas no sensibles. Tres librerías, tres wrappers, tres enums, cada wrapper un único archivo pequeño.
El resultado: tokens guardados bajo llave en el keystore de la plataforma y fuera de las copias en la nube, un PIN que no se puede leer sin biometría o el código del dispositivo, datos personales cifrados en reposo y preferencias que cargan en el primer frame. Cada dato está protegido en el nivel que realmente necesita.
Los ejemplos de código en este post son de rn-warrendeleon, mi proyecto personal de React Native; el tag blog-2026-08 marca el estado exacto al que corresponden. Las configuraciones completas de SecureStore, EncryptedStore y Redux Persist están en el repo.