What it actually does on each platform
SecureStore is a thin wrapper over two native stores that behave differently enough to matter:
- iOS:the Keychain. Values are encrypted by the OS, tied to your app’s identity, and — this is the important part — not deleted when the app is deleted.
- Android: a SharedPreferences entry whose value is encrypted with a key held in the Android Keystore, which is hardware-backed on most modern devices. Uninstalling the app removes it.
Both are at-restprotections. They stop someone who has your device’s filesystem from reading the value. They do not stop code running inside your own app, and they do not turn a value that was already public into a secret.
Install and the four calls you need
It contains native code, so installing it means a new development build — it will not appear by magic in a running Expo Go session. If you are unsure which you are on, the Expo Go vs development build breakdown covers the difference.
npx expo install expo-secure-store # then rebuild the dev client: npx expo run:ios # or: eas build --profile development --platform ios
import * as SecureStore from 'expo-secure-store';
await SecureStore.setItemAsync('session_token', token);
const token = await SecureStore.getItemAsync('session_token'); // string | null
await SecureStore.deleteItemAsync('session_token');
const ok = await SecureStore.isAvailableAsync(); // false on webThree things about that API that cost people an afternoon:
- Values are strings only. Objects need
JSON.stringifygoing in — and remember the 2 KB ceiling applies to the stringified result. - A missing key returns null, it does not throw. So
JSON.parse(await getItemAsync(k))on a fresh install parsesnulland gives younullrather than an error. Guard before you parse. - Keys are restricted. Alphanumerics plus
.,-and_. A key built from an email address or a URL will throw on iOS.
Which store for which value
| Store | Encrypted? | Practical size | Speed | Use it for |
|---|---|---|---|---|
| SecureStore | ✅ OS-backed | ~2 KB per value | Slow (async, Keychain round-trip) | Session tokens, refresh tokens, PINs |
| AsyncStorage | ❌ Plain text | 6 MB default on Android | Fine for small values | Preferences, cache, onboarding flags |
| react-native-mmkv | ⚠️ Optional passphrase | Large | Fast, synchronous | Hot state read on every render |
| expo-sqlite | ❌ Plain file | Disk-bound | Query-shaped | Relational or offline data sets |
The rule that keeps this simple: credentials in SecureStore, everything else somewhere cheaper. A Keychain round-trip on every screen render is a real performance cost, and the 2 KB limit will find you the first time a JWT grows a few claims. The AsyncStorage guide covers the other side of that split.
One storage module, not Platform checks everywhere
SecureStore has no web implementation. If your Expo project also runs in a browser — and most do, even if only for development — every direct call is a crash waiting for the day someone opens the web target. Put the branch in one file:
// lib/secureStorage.ts
import { Platform } from 'react-native';
import * as SecureStore from 'expo-secure-store';
const isWeb = Platform.OS === 'web';
export async function getSecure(key: string): Promise<string | null> {
if (isWeb) return globalThis.localStorage?.getItem(key) ?? null;
return SecureStore.getItemAsync(key);
}
export async function setSecure(key: string, value: string) {
if (isWeb) return void globalThis.localStorage?.setItem(key, value);
await SecureStore.setItemAsync(key, value, {
keychainAccessible: SecureStore.WHEN_UNLOCKED,
});
}
export async function clearSecure(key: string) {
if (isWeb) return void globalThis.localStorage?.removeItem(key);
await SecureStore.deleteItemAsync(key);
}Be honest with yourself about what that web branch means: localStorage is readable by any script on the page. It is a development convenience, not parity. If the web build is a real product surface, tokens belong in an HTTP-only cookie set by your server instead.
keychainAccessible: the option that decides background reads
This iOS-only option controls when the value can be decrypted. Pick the wrong one and your app works perfectly in the foreground while every background refresh silently gets null.
| Level | Readable when | Pick it for |
|---|---|---|
WHEN_UNLOCKED | The default. Readable only while the device is unlocked. | Anything read during normal app use. |
AFTER_FIRST_UNLOCK | Readable after the first unlock following a reboot, including in the background. | A token a background fetch or push handler must read. |
WHEN_UNLOCKED_THIS_DEVICE_ONLY | Same as the default, but never syncs or restores to another device. | Device-bound secrets you do not want in an iCloud backup. |
AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY | Background-readable and excluded from backups. | Background tokens on a shared or managed device. |
There is also requireAuthentication: true, which puts Face ID or a device passcode in front of every read. It is the right call for a banking-style reveal-my-balance gate and the wrong call for a session token, because a token read happens on every cold start and nobody wants a biometric prompt at launch. If you do use it, iOS needs an NSFaceIDUsageDescription string or App Review rejects the build.
The uninstall trap
This is the bug that reaches you as “I deleted the app and reinstalled it and it logged me straight into my old account.” On iOS, Keychain items belong to the developer, not the installed binary, so a reinstall reads the token the previous install wrote. On Android the same code starts clean. Your test device probably runs one of the two, which is why this ships.
The fix is a marker in a store that does get wiped on uninstall:
import AsyncStorage from '@react-native-async-storage/async-storage';
import { clearSecure } from './secureStorage';
const INSTALL_FLAG = 'install_seen_v1';
export async function clearCredentialsOnFreshInstall() {
const seen = await AsyncStorage.getItem(INSTALL_FLAG);
if (seen) return; // normal launch, leave the session alone
await clearSecure('session_token'); // stale Keychain entry from a prior install
await clearSecure('refresh_token');
await AsyncStorage.setItem(INSTALL_FLAG, '1');
}Call it once, before your auth provider reads the token. The cost is one AsyncStorage read at launch; the benefit is that a reinstall means what the user thinks it means.
What SecureStore cannot do
The name oversells it, so here is the boundary drawn plainly. SecureStore protects a value after your code writes it, at rest, from other software on the device. It does not:
- Protect a key you shipped in the bundle. Reading it out of your JavaScript to write it into SecureStore is theatre — it was already readable in the build. Server keys go on a server; see Expo environment variables.
- Survive a compromised device. On a jailbroken or rooted phone with a debugger attached to your process, the value is readable at the moment your app decrypts it.
- Make a long-lived token safe. Storage hygiene is not a substitute for short expiry and server-side revocation. A refresh token you can invalidate beats a perfectly stored one you cannot.
- Encrypt anything else in your app. Data your app writes to AsyncStorage, SQLite, or the filesystem stays exactly as exposed as it was.
Used inside those lines it is genuinely the correct tool, and the whole integration is about fifteen lines of code.
Get it wired without writing it
If you are building the app rather than studying the API, this is table stakes plumbing you should not be hand-rolling. Describe your app at shipnative.dev and the generated React Native project comes with the auth flow, the storage module, and the token lifecycle already wired — then export the full Expo project and change whatever you want.