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 |
Installation
Section titled “Installation”npm install @symbiote-native/secure-storeScaffolding 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> );}<script setup lang="ts">import { ref } from 'vue';import { deleteItemAsync, getItemAsync, setItemAsync } from '@symbiote-native/secure-store/vue';
const token = ref<string | null>(null);
function onSave() { void setItemAsync('session-token', 'abc123');}
function onRead() { void getItemAsync('session-token').then(value => { token.value = value; });}
function onForget() { void deleteItemAsync('session-token').then(() => { token.value = null; });}</script>
<template> <view> <text>{{ token ?? 'no token stored' }}</text> <button title="Save" @press="onSave" /> <button title="Read" @press="onRead" /> <button title="Forget" @press="onForget" /> </view></template>import { Component, signal } from '@angular/core';import { SYMBIOTE_ELEMENTS } from '@symbiote-native/angular';import { deleteItemAsync, getItemAsync, setItemAsync } from '@symbiote-native/secure-store/angular';
@Component({ standalone: true, imports: [SYMBIOTE_ELEMENTS], template: ` <view> <text>{{ token() ?? 'no token stored' }}</text> <button title="Save" (press)="onSave()" /> <button title="Read" (press)="onRead()" /> <button title="Forget" (press)="onForget()" /> </view> `,})export class SessionToken { readonly token = signal<string | null>(null);
onSave(): void { void setItemAsync('session-token', 'abc123'); }
onRead(): void { void getItemAsync('session-token').then(value => this.token.set(value)); }
onForget(): void { void deleteItemAsync('session-token').then(() => this.token.set(null)); }}There’s no per-instance service to inject() here — every function is a plain export off the
core package, called straight from a template event binding.
<script lang="ts"> import { deleteItemAsync, getItemAsync, setItemAsync } from '@symbiote-native/secure-store/svelte';
let token = $state<string | null>(null);
function onSave(): void { void setItemAsync('session-token', 'abc123'); }
function onRead(): void { void getItemAsync('session-token').then(value => (token = value)); }
function onForget(): void { void deleteItemAsync('session-token').then(() => (token = null)); }</script>
<view> <text>{token ?? 'no token stored'}</text> <button title="Save" onPress={onSave} /> <button title="Read" onPress={onRead} /> <button title="Forget" onPress={onForget} /></view>There’s no per-instance rune to reach for here either — every function is a plain export off
the core package, called straight from onPress.
import { createSignal } from 'solid-js';import { deleteItemAsync, getItemAsync, setItemAsync } from '@symbiote-native/secure-store/solid';
export function SessionToken() { const [token, setToken] = createSignal<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> );}There’s no per-instance primitive to reach for here either: every function is a plain export
off the core package, called straight from onPress.
Values are strings. JSON-encode anything else:
await setItemAsync('profile', JSON.stringify(profile));const profile = JSON.parse((await getItemAsync('profile')) ?? 'null');Behind the device’s own biometrics
Section titled “Behind the device’s own biometrics”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.
Functions
Section titled “Functions”| 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.
ISecureStoreOptions
Section titled “ISecureStoreOptions”| 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 |
Accessibility constants
Section titled “Accessibility constants”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
requireAuthenticationwhenever enrolled biometrics change — a new fingerprint, a re-registered face.getItemAsyncthen resolvesnull. Treat that as “the user has to sign in again”, not as an error to retry. requireAuthenticationdoes not combine with a sharedkeychainService. 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)returnsnulltoo, so guard with?? 'null'or an explicit check before using the result. - The synchronous pair blocks the JavaScript thread. With
requireAuthenticationon, the app stays unresponsive until the user authenticates — prefer the async functions unless you genuinely need a value during a synchronous render path.
Common questions
Section titled “Common questions”- 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_ONLYaccessibility variants. getItemAsyncreturnsnull. Either no entry exists or the key was invalidated (for example biometrics changed withrequireAuthentication). Treatnullas “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?.
How the wrapper works
Section titled “How the wrapper works”@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).