skip to content

In an Expo app, why is expo-secure-store the wrong place for a large JSON payload such as a cached crypto portfolio, and what should hold it instead?

level: middleimportance: nice to knowfreq 15%

answer

  1. small secrets only
  2. no limit enforced by Expo
  3. platform may reject big payloads
  4. store the key, not the data
  5. handle native errors on save

basics

~20 s

expo-secure-store is meant for small secrets: Expo enforces no size limit, but the platform can reject large payloads, and some iOS releases refused values above about 2 KB. Keep the portfolio in a database or file, and store only its encryption key in SecureStore.

solid answer

~40 s

SecureStore wraps the iOS Keychain and a Keystore-encrypted SharedPreferences entry, both designed for credentials, not data sets. The expo-secure-store docs warn that large payloads can be rejected by the platform, that some iOS releases historically refused values above roughly 2048 bytes, and that Expo does not enforce a limit, so a big `setItemAsync` may reject on some devices only. Each read also crosses into native code and decrypts the whole value, which is wasteful for data the UI reads often. The better design is to put the cached portfolio in expo-sqlite or a file, encrypted if it is sensitive, for example with SQLCipher, and keep only the small key, generated randomly per install, in SecureStore. Always handle a rejected `setItemAsync` instead of assuming the write succeeded.

code

typescript · 14 lines
typescript
import * as SecureStore from 'expo-secure-store';

const KEY_NAME = 'portfolio_db_key';

// createKey must come from a cryptographically secure random source, e.g. 32 bytes as 64 hex chars.
export async function getOrCreateDbKey(createKey: () => string): Promise<string> {
  const existing = await SecureStore.getItemAsync(KEY_NAME);
  if (existing !== null) return existing;
  const key = createKey(); // a few dozen characters, far below any platform limit
  await SecureStore.setItemAsync(KEY_NAME, key, {
    keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY,
  }); // rejects on failure; callers must handle it
  return key;
}

go deeper

for a junior

Recall that SecureStore is for small secrets such as tokens and keys, and that large values may be rejected by the platform.

for a middle

Explain why size failures are device-dependent, Expo enforces no limit, and why the store should hold a key while data lives in a database or file.

for a senior

Apply the envelope pattern, handle rejected writes, choose accessibility for the key, and make the encrypted cache disposable if the key is lost.

for a principal

Classify the app's data into secrets, sensitive bulk data and public cache, and assign each a store and a recovery plan.

## What the store is built for `expo-secure-store` sits on the operating system's credential facilities: - on iOS, **Keychain** items of the generic-password class; - on Android, values encrypted with an **Android Keystore** AES key and written to a private SharedPreferences file. Both are designed for **credentials**: tokens, passwords, keys. They are not designed as a data store for lists of records, cached API responses or documents. ## What the docs say about size The SDK 57 docs are specific and deliberately cautious: - large payloads **can be rejected** by the underlying platform; - **historically, some iOS releases refused values above roughly 2048 bytes**; - **Expo does not enforce a limit**, so you must handle native errors when storing large strings. The practical consequence is worse than a clean limit. A crypto tracker that stores a 40 KB JSON portfolio with `setItemAsync` can pass every test on the team's devices and then fail on some users' devices, where the promise rejects and the portfolio silently is not cached, if the code does not handle the rejection. ## Other costs of big values - **Every read is whole.** `getItemAsync` returns the entire string, decrypted, and crosses from native code into JavaScript. A screen that re-reads a large value often pays that every time, and `JSON.parse` of a large string adds to it on the JS thread. - **No querying.** You cannot ask for one holding, sort, or update a single field without rewriting the whole value. - **Synchronous variants block.** `getItem` on a large value stalls the JavaScript thread for the whole native call. ## The envelope pattern The standard design splits **the data** from **the key that protects it**: 1. Generate a random key on first use (a few dozen bytes, hex- or base64-encoded). 2. Store that key in SecureStore, for example under `db_key`, with a suitable `keychainAccessible`. 3. Store the portfolio in a database or file that is encrypted with that key: expo-sqlite built with SQLCipher and keyed with `PRAGMA key`, or an encrypted file. 4. At launch, read the key once, open the encrypted store, and keep the key in memory only as long as needed. | What | Where | Why | |---|---|---| | Session and refresh tokens | SecureStore | small, sensitive, reissuable | | Database or file encryption key | SecureStore | small, sensitive, protects bulk data | | Cached portfolio, price history | expo-sqlite or a file | large, queried, updated often | | Non-sensitive settings | ordinary key-value storage | no secret to protect | If the portfolio is not actually sensitive (public prices, public coin metadata), skip encryption and cache it in ordinary storage; SecureStore adds nothing there. ## Signs it is being misused - Values built with `JSON.stringify` of arrays that grow with usage, such as a watchlist or alert history. - Many keys with numbered suffixes, a sign that someone split a large value to dodge size failures. - Reads of SecureStore inside render paths or list items, instead of once at startup. - Non-secret data such as theme or last screen stored there "just to be safe"; that belongs in ordinary storage. Each of these is a design problem rather than a bug to patch; the fix is moving the data, not tuning the calls. A useful review question for any new `setItemAsync` call: would losing this value only force a sign-in or a refetch? If yes, SecureStore is a fine home. If losing it would lose user data, it needs a real store and a backup story, with SecureStore holding at most the key. ## Writing defensively Whatever goes into SecureStore: - **await `setItemAsync` and handle rejection.** Show an error or fall back; do not assume the write happened. - **Keep values strings.** Values must be strings; JSON-encode small structured secrets and nothing larger. - **Watch key names.** Keys may only contain alphanumeric characters, `.`, `-` and `_`. - **Plan for loss.** SecureStore is not a single source of truth; a lost encryption key means the encrypted cache must be rebuilt from the server, so design the cache to be disposable.

  • What should the app do when setItemAsync rejects for a large value?
    Treat it as a real failure: log it, keep the data in memory for the session or fall back to another store, and do not report success to the user. Better still, redesign so only a small key goes into SecureStore and the data lives elsewhere.
  • If the encryption key in SecureStore is lost, for example after a reinstall on Android, what happens to the encrypted cache?
    It cannot be decrypted any more, so the app must delete it and rebuild it from the server. That is why a cache encrypted this way should be disposable, and why SecureStore should never hold the only copy of anything irreplaceable.

SecureStore is a safe-deposit box: it is the right place for the key to your storage unit, not for the furniture. Keep the furniture in the unit, lock it, and put only the small key in the box.

saying these in an interview costs you the question

  • Expo enforces a 2 KB limit and rejects larger values up front
  • Anything sensitive, however large, belongs directly in SecureStore
  • A setItemAsync that did not throw in testing will work on every device
  • Storing the whole portfolio in SecureStore makes reads cheap