Skip to content

Secure store

Store tokens and other small secrets encrypted on the device, optionally behind the user’s fingerprint, face or passcode. @symbiote-native/secure-store wraps expo-secure-store (iOS Keychain, Android Keystore) so every SymbioteNative adapter can reach it, not just React. Like store review and local auth, every export is a free function with no per-instance state, so there is no hook/composable/service to wrap — the React, Vue, Angular, Svelte, and Solid entry points are plain re-exports of the same core.

OS platform Support
iOS ✅ live
Android ✅ live
Framework adapter Support
React ✅ live
Vue ✅ live
Angular ✅ live
Svelte ✅ live
Solid ✅ live
Terminal window
npm install @symbiote-native/secure-store

Scaffolding or extending a SymbioteNative app? npx @symbiote-native/cli new --secure-store (or add --secure-store in an existing app) installs and wires this for you — see @symbiote-native/cli.

expo-secure-store and expo-modules-core come along as regular dependencies, pinned to exact versions — never install either yourself, and never add the expo meta-package to your project (it bundles its own Metro/Babel pipeline, which conflicts with this project’s own).

The package’s native-link.json also asks symbiote-expo-link for a NSFaceIDUsageDescription string on iOS (needed the moment requireAuthentication raises a Face ID prompt) and for the two Android Auto Backup attributes below. Both land automatically on install; neither overwrites a value your app already set.

All five adapters (React, Vue, Angular, Svelte, Solid) re-export the exact same functions; there’s no per-adapter hook/composable/service to reach for, since nothing here holds live state or a subscription.

import { useState } from 'react';
import { deleteItemAsync, getItemAsync, setItemAsync } from '@symbiote-native/secure-store/react';
export default function SessionToken() {
const [token, setToken] = useState<string | null>(null);
return (
<view>
<text>{token ?? 'no token stored'}</text>
<button title="Save" onPress={() => setItemAsync('session-token', 'abc123')} />
<button title="Read" onPress={() => getItemAsync('session-token').then(setToken)} />
<button title="Forget" onPress={() => deleteItemAsync('session-token').then(() => setToken(null))} />
</view>
);
}

Values are strings. JSON-encode anything else:

await setItemAsync('profile', JSON.stringify(profile));
const profile = JSON.parse((await getItemAsync('profile')) ?? 'null');
if (canUseBiometricAuthentication()) {
await setItemAsync('session-token', token, {
requireAuthentication: true,
authenticationPrompt: 'Unlock your saved session',
});
}

The prompt fires at different moments per platform: Android authenticates on every operation, iOS only when reading or updating an entry that already exists. A simulator or emulator does not enforce it at all, so this option can only be verified on a real device.

Signature Description
isAvailableAsync(): Promise<boolean> Whether the SecureStore API is usable on this device. Resolves true on Android and iOS. Says nothing about app permissions
getItemAsync(key, options?): Promise<string | null> Reads the stored value. null when there is no entry for the key, or when the key has been invalidated
getItem(key, options?): string | null Same read, synchronously. Blocks the JavaScript thread
setItemAsync(key, value, options?): Promise<void> Stores a key–value pair. Rejects if the value cannot be stored
setItem(key, value, options?): void Same write, synchronously. Blocks the JavaScript thread
deleteItemAsync(key, options?): Promise<void> Deletes the value stored under key
canUseBiometricAuthentication(): boolean Whether a value can be stored with requireAuthentication — true when the device supports biometrics and the enrolled method is strong enough

Keys may contain alphanumeric characters, ., - and _, and must be non-empty. Anything else throws before the native call is made.

Field Type Description
keychainService string | undefined Android: the key pair’s Alias. iOS: the item’s kSecAttrService. An item stored with one needs the same one to be read back
requireAuthentication boolean | undefined Require the device’s own authentication to reach the value
authenticationPrompt string | undefined Message shown in the prompt raised by requireAuthentication
keychainAccessible IKeychainAccessibilityConstant | undefined When the entry is accessible, via iOS’s kSecAttrAccessible. iOS only. Defaults to WHEN_UNLOCKED
accessGroup string | undefined The keychain access group the entry belongs to. iOS only

Values for options.keychainAccessible, exported from the package root. iOS only — Android’s native module declares none of them, so they read undefined there.

Constant Meaning
WHEN_UNLOCKED Readable only while the device is unlocked. The default
WHEN_UNLOCKED_THIS_DEVICE_ONLY Same, and never migrated to a new device by a backup
AFTER_FIRST_UNLOCK Readable after the device has been unlocked once since boot — including while locked afterwards
AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY Same, and never migrated to a new device by a backup
WHEN_PASSCODE_SET_THIS_DEVICE_ONLY Requires a passcode to store at all; removing the passcode deletes the entry
ALWAYS Readable regardless of lock state. Deprecated upstream — least secure
ALWAYS_THIS_DEVICE_ONLY Same, never migrated by a backup. Deprecated upstream
  • An invalidated key is gone for good. The system invalidates entries stored with requireAuthentication whenever enrolled biometrics change — a new fingerprint, a re-registered face. getItemAsync then resolves null. Treat that as “the user has to sign in again”, not as an error to retry.
  • requireAuthentication does not combine with a shared keychainService. The full behavior needs a freshly generated key, so reusing a service that already holds non-authenticated entries gives partial behavior. Upstream documents the same limitation.
  • Keep values small. Expo does not enforce a limit, but the platform can reject large payloads: historically iOS refused values above about 2048 bytes, and some Android devices fail near 4072 characters. The limit applies to the stringified value. Store a token or a key here, not a document, and handle the rejection from setItemAsync.
  • iOS Keychain entries can outlive an uninstall. A reinstalled app may still read the previous install’s values. If that matters, keep a first-run marker elsewhere and clear the store when it is missing.
  • A missing key reads null, not an error. JSON.parse(null) returns null too, so guard with ?? 'null' or an explicit check before using the result.
  • The synchronous pair blocks the JavaScript thread. With requireAuthentication on, the app stays unresponsive until the user authenticates — prefer the async functions unless you genuinely need a value during a synchronous render path.
  • Is there a size limit? Historically iOS refused values over about 2048 bytes. Expo does not enforce it, so handle the native error and keep large blobs in the file system, storing only a key here.
  • I reinstalled the app and the old token is still there. The iOS Keychain survives uninstall for the same bundle ID. Clear it on first launch, or use the THIS_DEVICE_ONLY accessibility variants.
  • getItemAsync returns null. Either no entry exists or the key was invalidated (for example biometrics changed with requireAuthentication). Treat null as “sign in again”.
  • When does the biometric prompt show? On iOS only when reading or updating an existing value, not when creating one.
  • Is it encrypted on Android? Values live in SharedPreferences, encrypted with the Android Keystore.

Sources: Expo docs: SecureStore, expo/expo#4084, Expo SecureStore: Tokens, Limits, and the Uninstall Trap, Does Expo SecureStore actually encrypt your data on Android?.

@symbiote-native/secure-store ships zero React/Vue/Angular/Svelte/Solid logic — expo-secure-store’s own JS is hand-ported into this package’s core/, resolving the native module through expo-modules-core’s requireNativeModule rather than the expo meta-package this project never installs:

packages/secure-store/src/
├── core/ # framework-agnostic: the get/set/delete surface plus the seven accessibility
│ # constants. native-module.ts resolves ExpoSecureStore via requireNativeModule
├── react/ # @symbiote-native/secure-store/react — export * from '../core'
├── vue/ # @symbiote-native/secure-store/vue — export * from '../core'
├── svelte/ # @symbiote-native/secure-store/svelte — export * from '../core'
├── angular/ # @symbiote-native/secure-store/angular — export * from '../core'
└── solid/ # @symbiote-native/secure-store/solid — export * from '../core'

Same shape as store review’s and local auth’s five adapter entries: single-file re-exports with no lifecycle code, since every export is a stateless free function — there is nothing for a hook, composable, rune, or service to subscribe to or clean up. The native code itself is never vendored or copied — expo-modules-autolinking resolves it straight out of node_modules (see the native setup guide).