Secure Storage
expo-secure-store keeps tokens in the iOS keychain and Android keystore, with small values and real limits.
SecureStore.setItemAsync(key: string, value: string, options?: SecureStoreOptions): Promise<void>AsyncStorage writes plain text that anyone with the device's file system can read. expo-secure-store writes through the platform's own secret store instead: the keychain on iOS, and on Android an encrypted value whose key lives in the Android keystore. Install it with npx expo install expo-secure-store.
The API
Three calls do the work: setItemAsync(key, value, options?), getItemAsync(key, options?) and deleteItemAsync(key, options?). Values are strings, as with AsyncStorage. isAvailableAsync() tells you whether the store exists on this platform at all — it is not supported on web, so a project that also builds for web needs a fallback.
What it actually guarantees
The value is encrypted at rest and tied to this device. That defeats someone reading a backup or browsing the file system. It does not defeat a jailbroken or rooted device, a debugger attached to your process, or a user who grants access to a malicious app. It also does nothing for secrets you ship inside the app bundle: those are readable by anyone who downloads it, so an API key in your code is public no matter where you copy it at runtime.
Keep it small
This is a place for a token, not a payload. Large values can be rejected by the platform; historically some iOS releases refused values above roughly 2048 bytes. Store the refresh token here and the user's display name in AsyncStorage.
Example
import * as SecureStore from 'expo-secure-store';
const ACCESS_TOKEN = 'auth.accessToken';
// The same options object must be used for every call on this key.
const OPTIONS: SecureStore.SecureStoreOptions = {
keychainService: 'com.example.app.auth',
// iOS only: readable in the background once the device has been unlocked once
// since boot. The default, WHEN_UNLOCKED, fails while the screen is locked.
keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK,
};
export async function saveAccessToken(token: string): Promise<void> {
await SecureStore.setItemAsync(ACCESS_TOKEN, token, OPTIONS);
}
export async function readAccessToken(): Promise<string | null> {
// Returns false on web, where there is no keychain or keystore.
if (!(await SecureStore.isAvailableAsync())) {
return null;
}
return SecureStore.getItemAsync(ACCESS_TOKEN, OPTIONS);
}
export async function clearAccessToken(): Promise<void> {
// Deleting a key that is not there is not an error.
await SecureStore.deleteItemAsync(ACCESS_TOKEN, OPTIONS);
}When to use it
- Keep a refresh token across launches so the user is not asked to sign in every morning.
- Store a device-specific API credential issued at registration.
- Hold a wallet or vault PIN behind Face ID or a fingerprint.
- Stash an OAuth code verifier for the few seconds between opening the browser and the redirect back.
More examples
A gate that waits for the token
On screen: a spinner, then one line of text once the keychain read resolves. Keychain reads are slower than a file read, and on iOS they can be blocked entirely while the device is locked, so a launch path that reads a token needs a real waiting state rather than assuming the answer is available on the first render. Note the string is inside <Text> — a bare string in a <View> throws.
import { useEffect, useState } from 'react';
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
import { readAccessToken } from './session';
type Status = 'checking' | 'signed-in' | 'signed-out';
export default function AuthGate() {
const [status, setStatus] = useState<Status>('checking');
useEffect(() => {
readAccessToken().then((token) => {
setStatus(token === null ? 'signed-out' : 'signed-in');
});
}, []);
return (
<View style={styles.screen}>
{status === 'checking' ? (
<ActivityIndicator />
) : (
<Text style={styles.text}>{status === 'signed-in' ? 'Welcome back' : 'Please sign in'}</Text>
)}
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, alignItems: 'center', justifyContent: 'center' },
text: { fontSize: 18 },
});Putting a value behind biometrics
With requireAuthentication the platform prompts before handing the value over. iOS prompts on read and update; Android prompts for every operation and needs API level 23 or higher. Guard it with canUseBiometricAuthentication, because writing with the flag on a device that has nothing enrolled fails. Always keep a recovery path — if the user later removes every biometric and passcode, that entry can become unreadable, and sign-in again must still work.
import * as SecureStore from 'expo-secure-store';
export async function savePin(pin: string): Promise<void> {
// Synchronous check: is there a fingerprint or face enrolled right now?
const canUseBiometrics = SecureStore.canUseBiometricAuthentication();
await SecureStore.setItemAsync('wallet.pin', pin, {
requireAuthentication: canUseBiometrics,
authenticationPrompt: 'Confirm to unlock your wallet',
});
}Deciding what goes where
The split people get wrong is dumping a whole session object into the secure store. It is size-capped and slower, and the profile fields are not secrets. Keep the credential in the keychain, the display data in AsyncStorage, and the header on your profile screen renders instantly while the token read is still in flight.
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as SecureStore from 'expo-secure-store';
type Session = { refreshToken: string; userName: string; avatarUrl: string };
export async function persistSession({ refreshToken, userName, avatarUrl }: Session) {
// Secret, small, and worth the slower write.
await SecureStore.setItemAsync('auth.refreshToken', refreshToken);
// Not secret. Cheap to read on launch, and no size limit worth worrying about.
await AsyncStorage.multiSet([
['profile.userName', userName],
['profile.avatarUrl', avatarUrl],
]);
}
Discussion