Shipnative
ShipnativeShipnative
Sign in
GuideAugust 2026 · 9 min read

Expo SecureStore: Tokens, Limits, and the Uninstall Trap

expo-secure-store is the right place for a session token and the wrong place for almost everything else. It is a small API — four functions you will actually use — but it sits on top of two very different operating-system stores, and the places it surprises people are all in that gap: a 2 KB ceiling, values that outlive the app on iOS, and a name that makes founders think it will protect an API key it was never able to protect. Here is the whole surface, with the parts that bite.

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 web

Three things about that API that cost people an afternoon:

  1. Values are strings only. Objects need JSON.stringify going in — and remember the 2 KB ceiling applies to the stringified result.
  2. A missing key returns null, it does not throw. So JSON.parse(await getItemAsync(k)) on a fresh install parses null and gives you null rather than an error. Guard before you parse.
  3. 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

StoreEncrypted?Practical sizeSpeedUse it for
SecureStore✅ OS-backed~2 KB per valueSlow (async, Keychain round-trip)Session tokens, refresh tokens, PINs
AsyncStorage❌ Plain text6 MB default on AndroidFine for small valuesPreferences, cache, onboarding flags
react-native-mmkv⚠️ Optional passphraseLargeFast, synchronousHot state read on every render
expo-sqlite❌ Plain fileDisk-boundQuery-shapedRelational 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.

LevelReadable whenPick it for
WHEN_UNLOCKEDThe default. Readable only while the device is unlocked.Anything read during normal app use.
AFTER_FIRST_UNLOCKReadable 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_ONLYSame 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_ONLYBackground-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.

Frequently Asked Questions

What is the difference between SecureStore and AsyncStorage?

AsyncStorage is a plain key-value store — on Android it is an unencrypted SQLite file, on iOS a plain file in your app container. Anyone with a rooted device or a filesystem dump can read it. SecureStore writes to the iOS Keychain and to Android SharedPreferences encrypted with a key held in the Android Keystore, so the value is protected by the OS. Use AsyncStorage for preferences and cached data, SecureStore for tokens and anything you would call a credential.

How much data can Expo SecureStore hold?

The documented limit is 2048 bytes per value. Larger values may fail, and Expo currently prints a warning rather than throwing. It is a credential store, not a database — if you are approaching the limit you are storing the wrong thing. Store the token in SecureStore and the rest of the user object in AsyncStorage or SQLite.

Does SecureStore work in Expo Go?

Yes, but the values live inside the Expo Go sandbox, which is shared across every project you open in it, and they disappear when Expo Go is reinstalled. Options like requireAuthentication behave differently there too. Test auth persistence in a development build, not Expo Go.

Why is my SecureStore value still there after I deleted the app?

On iOS, Keychain items survive app deletion — that is Apple behaviour, not an Expo bug. A reinstall can read the old token and log the user into a stale session. Fix it by writing a first-launch flag to AsyncStorage (which is wiped on uninstall) and clearing SecureStore when that flag is missing. On Android the encrypted preferences are removed with the app, so the two platforms genuinely differ.

Can SecureStore protect my API key?

No. SecureStore protects values at rest on a device after your code puts them there. A key you ship inside the app bundle is already readable before SecureStore ever sees it — anyone can unzip the build and run strings on the JS bundle. Keys that grant billable access belong on a server behind an endpoint your app calls.

Does SecureStore work on web?

No. expo-secure-store has no web implementation, so a universal app needs a Platform.OS branch — typically localStorage or a cookie on web, SecureStore on native. Write that branch once behind your own storage module rather than sprinkling Platform checks through your auth code.

→

React Native Authentication in 2026

The full login flow SecureStore holds the token for.

Read guide →
→

Expo Environment Variables

What is and is not secret once it ships inside your bundle.

Read guide →

Ship a real React Native app today

Describe, preview, and export Expo code — free to start.

Build with ShipNative →
ShipNative logoShipnative

Build mobile apps with AI. Describe, preview, and ship to iOS & Android in minutes.

Features

Text to App AIApp Generator from ScreenshotPRD to Mobile App

Tools

All free toolsApp Cost CalculatorApp Name GeneratorApp Store Keyword ToolReact Native Components

Blog

All blog postsHow to Build an App Without CodingBest AI Tools for Real Mobile AppsExpo EAS App Store ChecklistLovable, Cursor & v0 for MobileBest AI App Builders in 2026React Native AI App Builder Guide

Legal

FAQTerms of ServicePrivacy Policy

© 2026 ShipNative. All rights reserved.