Tatlong pababang baitang na may nakakandadong cube, isang safe at isang bukas na tray, may sorting arrow

Token storage sa React Native: AsyncStorage vs SecureStore

Kung kailan hindi na sapat ang iisang storage layer

Nag-i-store ang karamihan ng React Native apps ng lahat sa AsyncStorage. Tokens, user data, preferences, session state. Lahat sa iisang lugar, lahat sa plain text.

Ang AsyncStorage ay isang key-value store na gumagamit ng SQLite sa Android at mga file sa disk sa iOS. Mabilis at maginhawa. Pero walang encryption. Kahit sinong makakaabot sa mga file ng app, sa rooted o jailbroken na device o sa naka-unlock na phone, ay makakabasa sa bawat value.

Para sa theme preference, okay lang iyan. Para sa access token, isang incident na iyan.

Tatalakayin ng post na ito ang tatlong tier na ginagamit ko sa production: ang platform keystore para sa tokens, encrypted store para sa PII, at AsyncStorage (sa pamamagitan ng Redux Persist) para sa preferences. Maikli lang ang wrapper ng bawat tier. Ang trabaho ay nasa pagdedesisyon kung saan napupunta ang bawat data, at sa pagpapanatili ng hangganang iyon sa auth flow mo.

Mga assumption

Isinulat ang walkthrough na ito para sa:

  • React Native 0.74+ (bare workflow, hindi Expo)
  • TypeScript gamit ang standard na RN Babel config
  • Redux Toolkit + Redux Persist para sa state management
  • iOS 13+ at Android API 23+ (kailangan ng Keystore code path ang API 23 bilang minimum)
  • Isang Supabase backend (o kahit anong REST API na nagbabalik ng access/refresh tokens)

Sa Expo, palitan ang react-native-keychain ng expo-secure-store sa Tier 1 wrapper. Mananatiling pareho ang structure.

Ang tatlong tier

TierLibrarySecurityBilisGamitin para sa
1. SecureStorereact-native-keychainOS keystore (Keychain/Keystore)PinakamabagalTokens, hashed PINs
2. EncryptedStorereact-native-encrypted-storageAES-256 encryptionKatamtamanPII (email, pangalan, phone)
3. AsyncStorage@react-native-async-storageWala (plain text)PinakamabilisPreferences (theme, language)

Manipis na wrapper sa isang library ang bawat tier. Ipinapatupad ng wrapper ang typed keys (para hindi mo mai-store ang token sa maling tier) at nagbibigay ito ng consistent na API.

Tier 1: SecureStore (Keychain / Keystore)

Ang pinakamataas na tier. Gumagamit ng sariling key storage ng platform: iOS Keychain o Android Keystore. Ine-encrypt ng OS mismo ang data (hardware-backed sa iOS; sa Android, depende sa device, tingnan ang paalala sa ibaba) at puwedeng mangailangan ng biometric authentication para mabasa.

yarn add react-native-keychain@10.0.0
cd ios && pod install && cd ..

Ang react-native-keychain ay native module, kaya kailangan ng iOS ng pod install. Sa Android, itakda ang minSdkVersion = 23 (o mas mataas) sa android/build.gradle para maabot ang Keystore code path.

Isang paalala sa Android: kahit sa API 23+, nakadepende sa device at OEM kung talagang mapupunta ang mga key sa isang Trusted Execution Environment o StrongBox. May mga modernong phone pa ring nagre-report ng software-only na storage. Kung kailangan ng threat model mo ng garantiya, tawagin ang Keychain.getSecurityLevel() sa runtime at i-gate ang mga sensitibong operasyon batay sa resulta. Ang iOS Keychain ay hardware-backed sa bawat suportadong device.

Ang 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';

// Mga key na nangangailangan ng re-authentication ng user bago mabasa. Nananatiling un-gated ang
// tokens para hindi kailanman mag-trigger ng prompt ang refresh interceptor at
// cold-start session check.
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;
  },
};

Apat na desisyon sa wrapper na iyan ang dapat banggitin:

  • Isang service bawat key. Iisang credential lang ang kayang i-store ng Keychain bawat service identifier. Ang paggamit ng com.warrendeleon.portfolio.accessToken at com.warrendeleon.portfolio.refreshToken bilang magkahiwalay na services ang pumipigil sa pag-overwrite sa isa’t isa.
  • Biometric gating na per key, hindi blanket. Ang BIOMETRY_ANY_OR_DEVICE_PASSCODE ay nangangahulugang ang pagbasa ng value ay puwedeng magpalabas ng Face ID / Touch ID / passcode prompt, kung kailan man mangyari ang read. I-gate ang mga key na dapat sadyang i-unlock ng tao (ang hashed PIN) at hayaang un-gated ang tokens; kung hindi, magpo-prompt sa user ang background token refresh at cold-start session check mo sa mga pagkakataong para bang bug ang dating.
  • Sa device na ito lamang. Pinapanatili ng WHEN_UNLOCKED_THIS_DEVICE_ONLY na hindi pumapasok ang data sa iCloud Keychain backups. Hindi dapat lumabas ang tokens sa device.
  • Typed enum keys. Hindi ka puwedeng mag-pass ng raw string nang hindi sinasadya. Ine-enforce ng compiler na token-level data lang ang pumapasok sa SecureStore.

Tier 2: EncryptedStore (AES-256)

Ang gitnang tier. Naka-encrypt ang data gamit ang AES-256, walang hardware-backed gate, walang biometric prompt. Mas mabilis kaysa Keychain, mas ligtas kaysa plain text.

yarn add react-native-encrypted-storage@4.0.3
cd ios && pod install && cd ..

Ang 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;
  },
};

Bakit hindi ilagay ang PII sa SecureStore? Dahil sa performance. Nangangailangan ang Keychain access ng system-level security check (at posibleng biometric prompt). Para lang maipakita ang pangalan ng user sa profile screen, hindi sulit ang overhead na ‘yon. Binibigyan ka ng EncryptedStore ng AES-256 encryption nang walang hardware gate, at ang library ang nagma-manage ng sarili nitong key material: hindi ka kailanman gagawa o magtatago ng encryption key.

Mahalaga ang batch operations (setMultiple, getMultiple) para sa auth flows kung saan kailangan mong i-store ang maraming fields nang sabay-sabay:

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 },
]);

Tier 3: AsyncStorage + Redux Persist

Ang pinakamabilis na tier. Plain text, walang encryption. Para lang sa data na walang security sensitivity: theme preference, language selection.

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 ..

Sa mga naka-pin na bersyong ito na-validate ang setup, sa blog-2026-08 tag ng repo.

Hindi mo ginagamit nang direkta ang AsyncStorage para sa preferences. Ang Redux Persist ang nagha-handle niyan. Awtomatiko nitong sini-save ang Redux state mo sa AsyncStorage at nire-rehydrate ito kapag nagla-launch ang app.

Nasa persist config ang security boundary:

// 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';

// Galing ang mga reducer sa store submodule ng bawat feature, hindi sa
// public barrel nito. Nag-e-export din ng mga screen ang barrel, at ang mga
// screen ay nag-i-import ng store, kaya ang pag-import nito rito ay
// nagsasara ng require cycle na nag-iiwan ng undefined na reducer.
import { authReducer } from '@app/features/Auth/store';
import { settingsReducer } from '@app/features/Settings/store';

// May sariling persist config ang auth slice para isang field lang ang mapasama sa whitelist.
const authPersistConfig = {
  key: 'auth',
  storage: AsyncStorage,
  whitelist: ['biometricEnabled'],
};

const persistedAuthReducer = persistReducer(authPersistConfig, authReducer);

const rootReducer = combineReducers({
  settings: settingsReducer,
  auth: persistedAuthReducer,
});

// Ang root persist config ang nagpe-persist lang sa settings slice (theme, language).
const rootPersistConfig = {
  key: 'root',
  storage: AsyncStorage,
  whitelist: ['settings'],
};

const persistedReducer = persistReducer(rootPersistConfig, rootReducer);

export const store = configureStore({
  reducer: persistedReducer,
  middleware: getDefaultMiddleware =>
    getDefaultMiddleware({
      serializableCheck: {
        // Nagdi-dispatch ang Redux Persist ng non-serialisable na actions habang nagre-rehydrate.
        // I-ignore ang mga ito para hindi mag-warn ang serialisable-check middleware.
        ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
      },
    }),
});

export const persistor = persistStore(store);
ConfigAno ang pini-persistAno ang hindi kasama
rootPersistConfigSettings slice lang (theme, language)Lahat ng iba
authPersistConfigbiometricEnabled flag languser, error, isLoading, tokens

Kritikal ang whitelist. Positive list ito: ang mga slice lang na papangalanan mo ang mape-persist; ephemeral ang lahat ng iba. Ganito mo pinipigilang mapunta nang aksidente ang mga token sa AsyncStorage sa pamamagitan ng 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; },
  },
});

Kapag nagpalit ang user ng theme o language, awtomatikong nagsusulat ang Redux Persist sa AsyncStorage. Sa susunod na launch, naghihintay ang PersistGate ng rehydration bago mag-render:

// 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}>
        {/* ang mga screen mo */}
      </PersistGate>
    </Provider>
  );
}

Hinaharangan ng PersistGate ang render hanggang sa muling ma-load ang persisted slice pabalik sa store. Kung wala ito, panandaliang lumalabas ang default state ng app nang isang frame bago pumalit ang naka-persist na theme/language.

Paano nagko-compose ang mga tier sa auth flow

Nagtutulungan ang tatlong tier sa login, session restore, logout, at token refresh.

Login

// 1. Nagbabalik ang backend ng tokens at user data
const { access_token, refresh_token, user } = await authClient.signIn(credentials);

// 2. Tokens → SecureStore (Tier 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. PII → EncryptedStore (Tier 2)
await EncryptedStore.set(EncryptedStoreKey.USER_EMAIL, user.email);
await EncryptedStore.set(EncryptedStoreKey.USER_FIRST_NAME, user.firstName);

// 4. Na-update ang Redux state → nagre-render ang UI
dispatch(setUser(user));
// Nasa Redux na ang settings (theme, language) sa pamamagitan ng Persist (Tier 3)

App startup (session restore)

export const checkSession = createAsyncThunk(
  'auth/checkSession',
  async () => {
    // Tingnan kung may valid token (Tier 1)
    const accessToken = await SecureStore.get(SecureStoreKey.ACCESS_TOKEN);
    if (!accessToken) return null;

    // I-restore ang user data (Tier 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);

    // Na-restore na ng PersistGate ang settings (Tier 3)
    return { id: userId, email, firstName };
  }
);

Logout

// 1. I-invalidate ang refresh token sa backend
await authClient.logout();

// 2. I-clear ang tokens (Tier 1)
await SecureStore.clear();

// 3. I-clear ang PII (Tier 2)
await EncryptedStore.clear();

// 4. I-clear ang Redux auth state
dispatch(resetAuth());

// Nananatili ang settings (Tier 3) pagkatapos mag-logout. Nananatili ang theme at language ng user.

Sinadya ang logout sequence. Kini-clear ang Tier 1 at Tier 2 dahil sa session nakatali ang tokens at PII. Nananatili ang Tier 3 dahil sa device nakatali ang theme at language.

Token refresh

Ang Axios interceptor ang nagha-handle ng token refresh sa background. Nagbabasa at nagsusulat ito sa SecureStore nang hindi ginagalaw ang ibang tiers:

import axios from 'axios';
import Config from 'react-native-config';

axiosInstance.interceptors.response.use(
  response => response,
  async error => {
    // Mag-retry lang nang isang beses bawat request, o ang refresh na patuloy na nagiging 401 ay iikot nang walang hanggan.
    if (error.response?.status === 401 && !error.config._retry) {
      error.config._retry = true;
      try {
        const refreshToken = await SecureStore.get(SecureStoreKey.REFRESH_TOKEN);
        // Gusto ng GoTrue ng Supabase ang grant_type bilang query parameter, at ang
        // bare na axios call ay nangangailangan ng absolute URL (walang baseURL dito).
        const { data } = await axios.post(
          `${Config.SUPABASE_URL}/auth/v1/token?grant_type=refresh_token`,
          { refresh_token: refreshToken },
          { headers: { apikey: Config.SUPABASE_ANON_KEY } }
        );

        // I-update ang tokens sa SecureStore
        await SecureStore.set(SecureStoreKey.ACCESS_TOKEN, data.access_token);
        await SecureStore.set(SecureStoreKey.REFRESH_TOKEN, data.refresh_token);

        // I-retry ang original request
        error.config.headers.Authorization = `Bearer ${data.access_token}`;
        return axiosInstance(error.config);
      } catch (refreshError) {
        // Walang refresh token, o nag-expire na ito: tapos na ang session. I-clear ang
        // secure tier para bumalik ang app sa login flow.
        await SecureStore.clear();
        return Promise.reject(refreshError);
      }
    }
    return Promise.reject(error);
  }
);

Pinipigilan ng _retry flag ang isang bigong refresh na umikot nang walang katapusan, at ang bigong refresh ay naglilinis ng secure tier para bumalik ang app sa login flow. Isang kaso ang hindi nito saklaw: kapag ilang request ang sabay-sabay na na-401, tig-iisang refresh ang pinapatakbo ng bawat isa. Ang pagsasama ng mga iyon sa iisang in-flight na refresh ay hiwalay na problema, at may sarili nitong post.

Ang data classification

Bawat piraso ng naka-store na data ay may malinaw na lugar:

DataTierBakit
Access token1 (SecureStore)Nagbibigay ng API access. Proteksyon ng keystore.
Refresh token1 (SecureStore)Puwedeng gumawa ng bagong access tokens. Pinakamataas na value target.
User ID1 (SecureStore)Ginagamit para tukuyin ang user sa bawat request.
Hashed PIN1 (SecureStore)Local authentication credential.
Email2 (EncryptedStore)PII. Naka-encrypt pero kailangan ng mabilis na access para sa display.
Pangalan2 (EncryptedStore)PII. Ipinapakita sa profile screens.
Phone number2 (EncryptedStore)PII. Ipinapakita sa settings.
Auth provider2 (EncryptedStore)Hindi sensitive pero konektado sa auth session.
Biometric preference3 (AsyncStorage sa pamamagitan ng Redux Persist)Isang preference, hindi sikreto. UX ang gina-gate nito, hindi access.
Theme3 (AsyncStorage)Hindi sensitive na preference. Nananatili pagkatapos mag-logout.
Language3 (AsyncStorage)Hindi sensitive na preference. Nananatili pagkatapos mag-logout.

Simple lang ang patakaran: kung nagbibigay ito ng access, Tier 1. Kung tumutukoy ito sa isang tao, Tier 2. Kung preference lang, Tier 3. Hinuhubog din ng classification ang ayos ng project. Nasa shared na utils/storage/ folder ang mga storage wrapper, at ang auth flow na namamahala sa mga ito ay nasa loob ng Auth feature.

Mga karaniwang pagkakamali

Huwag kalimutang nananatili ang iOS Keychain values pagkatapos ng uninstall. I-delete ang app, i-reinstall ito, at hawak pa rin ng Tier 1 ang mga lumang token habang bumabalik na walang laman ang Tier 2 at Tier 3. Ire-restore ng session check mo ang isang session na akala ng user ay tinapos na niya, nang walang profile data sa likod nito. I-detect ang first run (gumagana ang isang AsyncStorage sentinel, dahil nawi-wipe naman ang AsyncStorage) at i-clear ang SecureStore bago magbasa ng kahit ano mula rito.

Huwag mag-store ng tokens sa Redux. Ang Redux state ay puwedeng i-serialise, i-log, i-persist sa AsyncStorage ng Redux Persist, at i-inspect gamit ang DevTools. Kahit i-blacklist mo ang auth slice mula sa persistence, isang misconfiguration lang at nalantad na ang tokens. Panatilihin ang tokens sa SecureStore, walang ibang paraan.

Huwag i-skip ang typed enums. Kung walang SecureStoreKey at EncryptedStoreKey enums, nagpapasa ka ng raw strings. Isang typo at nagbabasa ka na sa maling key. Isang maling tier at nag-store ka na ng token sa plain text. Ang type system ang pinakamurang security audit mo.

Huwag kalimutang i-clear kapag nag-logout. Kung kini-clear mo ang SecureStore pero nakalimutan ang EncryptedStore, nananatili ang PII ng user pagkatapos niyang mag-logout. Kaya nandiyan ang clear() method sa bawat tier. Tawagin ang dalawang secure tier kapag nag-logout.

Huwag ipagpalagay na mabilis ang Keychain. Ang SecureStore ay may round trip sa secure enclave, at sa mas lumang devices, sapat ang bagal ng isang read para mapansin. Huwag itong tawagin sa render loop. Basahin ang tokens nang isang beses sa app startup at ipasa sa pamamagitan ng HTTP interceptor mo.

Gamitin ang Redux Persist whitelist, hindi blacklist. Pangalanan kung ano ang dapat i-persist. Delikado ang blacklist dahil nape-persist ang mga bagong slices by default. Isang bagong slice na may sensitive data at may leak ka na. Opt-in ang whitelist, at mas ligtas.

Kaya bakit tatlong libraries

Iniiwan ng isang library (AsyncStorage) ang tokens sa plain text. Masyadong mabagal ang isang library (react-native-keychain) para sa hindi sensitive na reads. Tatlong libraries, tatlong wrappers, tatlong enums, tig-iisang maliit na file bawat wrapper.

Ang resulta: tokens na nakakandado sa platform keystore at hindi kasama sa cloud backups, isang PIN na hindi mababasa nang walang biometrics o device passcode, PII na naka-encrypt at rest, at preferences na lumalabas sa unang frame. Bawat piraso ng data ay protektado sa level na talagang kailangan nito.

Ang mga code examples sa post na ito ay mula sa rn-warrendeleon, ang personal kong React Native project; ang blog-2026-08 tag ang nagmamarka ng eksaktong estadong tinutugma ng mga ito. Nasa repo ang kumpletong SecureStore, EncryptedStore, at Redux Persist configuration.

Warren de Leon
Warren de Leon

Software Engineering Manager. Pinakahuling pinamunuan ang Mobile Platform team sa Hargreaves Lansdown. Sumusulat tungkol sa engineering leadership, React Native, at pagbuo ng magagandang team.

Tingnan ang profile