In an Expo app, how do you save, read and delete a session token with expo-secure-store, and where does the value actually live on iOS and Android?
answer
- three async calls, one key
- missing key resolves null
- strings only; JSON-encode the rest
- Keychain item on iOS
- Keystore-encrypted prefs on Android
basics
~20 sCall SecureStore.setItemAsync(key, value) to save, getItemAsync(key) to read (it resolves null when nothing is stored) and deleteItemAsync(key) on sign-out. iOS keeps the value as a Keychain item; Android encrypts it with a Keystore key into SharedPreferences.
solid answer
~40 s`import * as SecureStore from 'expo-secure-store'`, then `await SecureStore.setItemAsync('session_token', token)` after sign-in, `await SecureStore.getItemAsync('session_token')` at launch, which resolves `null` when no entry exists, and `await SecureStore.deleteItemAsync('session_token')` on sign-out. Values must be strings, so JSON-encode anything else, and keys may only contain letters, digits, `.`, `-` and `_`; anything else throws. On iOS the value is a generic-password Keychain item; on Android it is encrypted with an AES key held in the Android Keystore and the ciphertext is saved in a `SecureStore` SharedPreferences file. There are synchronous `getItem` and `setItem` too, but they block the JavaScript thread. The module is not available on web: `isAvailableAsync()` resolves `true` only on Android and iOS.
code
typescript · 15 linesimport * as SecureStore from 'expo-secure-store';
const TOKEN_KEY = 'session_token';
export async function saveSession(token: string): Promise<void> {
await SecureStore.setItemAsync(TOKEN_KEY, token);
}
export async function loadSession(): Promise<string | null> {
return SecureStore.getItemAsync(TOKEN_KEY); // null when signed out
}
export async function clearSession(): Promise<void> {
await SecureStore.deleteItemAsync(TOKEN_KEY);
}go deeper
Recall the three calls, setItemAsync, getItemAsync and deleteItemAsync, that values are strings, and that a missing key resolves null.
Explain the storage on each platform, Keychain item on iOS and Keystore-encrypted SharedPreferences on Android, and the key and value validation rules.
Treat SecureStore as a secure cache: keep secrets small, avoid sync calls in the UI path, plan the web build, and never rely on it as the only copy.
Decide which credentials the device should hold at all, and for how long, given what the server can reissue and what a stolen device exposes.
## What expo-secure-store is for `expo-secure-store` stores **small secrets** as string key-value pairs, encrypted by the operating system's own credential facilities. In a crypto-price tracker, that is the session token the app sends with every price-alert request, or a refresh token. It is not a general database and not a cache; it is where a short, sensitive string goes. Each Expo project has its own separate storage, with no access to other projects' entries. ## The API in SDK 57 | Call | Returns | Notes | |---|---|---| | `setItemAsync(key, value, options?)` | `Promise<void>` | rejects if the value cannot be stored | | `getItemAsync(key, options?)` | `Promise<string \| null>` | `null` when there is no entry | | `deleteItemAsync(key, options?)` | `Promise<void>` | rejects if the value cannot be deleted | | `getItem(key, options?)` / `setItem(key, value, options?)` | value / `void` | synchronous, block the JS thread | | `isAvailableAsync()` | `Promise<boolean>` | `true` on Android and iOS only | | `canUseBiometricAuthentication()` | `boolean` | for the `requireAuthentication` option | Rules the library enforces in JavaScript before anything reaches native code: - **Keys** must be non-empty and contain only alphanumeric characters, `.`, `-` and `_`. A key such as `session token` or `user@example` throws. - **Values** must be strings. Passing an object or number throws an error suggesting you JSON-encode it. There is no synchronous delete; `deleteItemAsync` is the only removal call. ## Where the bytes live **iOS.** Each entry is a Keychain item of class `kSecClassGenericPassword`. The item's service attribute is derived from the `keychainService` option (with a default when you pass none), and its account is the key. The Keychain is managed by the operating system, encrypted by it, and governed by an accessibility attribute you can choose with `keychainAccessible` (default `WHEN_UNLOCKED`). **Android.** The module generates a 256-bit AES key inside the **Android Keystore**, where the key material cannot be exported, encrypts the value with AES-GCM, and writes the ciphertext as JSON into a private SharedPreferences file named `SecureStore`. Without the Keystore key, which only this app on this device can use, the file's contents are unreadable. ## The usual token flow 1. After sign-in, `setItemAsync('session_token', token)`. 2. At launch, `getItemAsync('session_token')`; `null` means show the sign-in screen. 3. On sign-out, or when the server rejects the token, `deleteItemAsync('session_token')`. Keep the token in memory once read, rather than reading it from SecureStore on every request. Each read crosses into native code and the platform's credential store. ## What it does not do - **It does not work on web.** The web implementation is empty, which is why `isAvailableAsync()` exists. A universal app needs another strategy for its web build. - **It is not meant for large data.** Expo does not enforce a size limit, but the platform can reject large payloads, and some iOS releases historically refused values above roughly 2 KB. - **It does not guarantee lifetime.** On iOS an entry can outlive an uninstall; on Android it does not survive one. Treat it as a secure cache of credentials the server can reissue, never the only copy of something irreplaceable. - **It does not make the rest of the app secure.** A token copied into a global store, a log line or plain storage is exposed there, whatever SecureStore does. ## Options must match on read The same `options` object shapes where an entry lives, so reads must use what writes used: - An item saved with a `keychainService` must be read and deleted with that same `keychainService`; the docs say it is required to fetch the value later. - On iOS, `accessGroup` likewise selects which shared Keychain group the item belongs to. - `keychainAccessible` only matters when the item is written. A helper module that owns the keys and their options, like the one in the example, prevents a screen from reading `session_token` with different options and getting `null` for a value that is actually there. ## Synchronous calls `getItem` and `setItem` exist for startup code that cannot await, but they block the JavaScript thread for the duration of the native call. With `requireAuthentication`, they block until the user finishes the biometric prompt, freezing the UI, so the async versions are the default choice.
- Why does SecureStore.setItemAsync('user token', t) throw before touching the Keychain?expo-secure-store validates keys in JavaScript: they must be non-empty and contain only alphanumeric characters, `.`, `-` and `_`. The space fails that check, so it throws an invalid-key error. Use `user_token` or `user.token` instead.
- How should a universal Expo app handle the web build if it uses expo-secure-store?The web implementation is empty, so check `isAvailableAsync()` or branch on the platform and use a web-appropriate mechanism there, typically keeping the session in an HttpOnly cookie managed by the server rather than storing the token in JavaScript.
saying these in an interview costs you the question
- getItemAsync throws when no value is stored for the key
- SecureStore accepts objects and serialises them for you
- On Android, SecureStore values are stored in plain SharedPreferences
- expo-secure-store works the same on web using localStorage
- Any string, including spaces or @, is a valid SecureStore key