Storage model
Services and keys, strings and bytes, options by instance and by call, labels, listing, and credentials.
The package is a key/value store, split into services. A service is a namespace with its own defaults; a key names one item in it; an item holds a string or bytes and a few attributes that can be read without a prompt.
Services and keys
keychain is the storage of the default service: the bundle id iOS or the applicationId Android. createKeychain gives the storage of another service:
import { AccessControl, createKeychain, keychain } from '@apexnative/keychain';
keychain.service; // 'com.example.app'
const vault = createKeychain({ service: 'vault', accessControl: AccessControl.BiometryCurrentSet });
const cache = createKeychain({ service: 'cache' });
Two storages of the same service see the same items; keys of different services never meet. clear() and getAllKeys() work on one service.
A key is a non-empty string of up to 256 characters, any Unicode except U+0000. A key that breaks the rule throws INVALID_ARGUMENT; so does a service that breaks it.
Only its own items
The package sees only the items it wrote. On iOS every query is limited to the package's mark (kSecAttrCreator), so the Keychain items of other libraries stay out of sight even when they use the same service: getItem returns null for them, getAllKeys doesn't list them, clear doesn't remove them. On Android the package keeps its items in files of its own, which no other library writes.
// react-native-keychain stored a password for the service 'auth' before.
const auth = createKeychain({ service: 'auth' });
await auth.getAllKeys(); // [] — rnk's item is there, but not this package's
A write doesn't overwrite another library's item either: on iOS, setItem under a key that another library's item holds in the same service fails with INVALID_ARGUMENT, and the other item stays as it was. Use another key, or move the old item first.
Items of react-native-keychain and expo-secure-store come over with the migration, which reads them where those libraries keep them: see From react-native-keychain and From expo-secure-store.
| iOS | Android | |
|---|---|---|
| An item | A generic password item: kSecAttrService is the service, kSecAttrAccount the key | An entry of the service's file in the app's no-backup directory, encrypted with a Keystore key |
| A service | Items with the same kSecAttrService | One file, <sha256(service)>.json |
On iOS and On Android describe the layout in full.
Strings and bytes
A value is a string, kept as UTF-8, or a Uint8Array:
await keychain.setItem('accessToken', 'eyJhbGciOi…');
const token: string | null = await keychain.getItem('accessToken');
await keychain.setBytes('dbKey', key); // Uint8Array
const bytes: Uint8Array | null = await keychain.getBytes('dbKey');
A string value can be any text, the empty string too, except two things that have no faithful way through the bridge to the native side:
- U+0000. React Native cuts a string at its first U+0000, so a value would come back shorter than it went in. A string with U+0000 throws
INVALID_ARGUMENT; so does alabel, a username or a password with one. - A lone surrogate, half of a UTF-16 pair. It has no UTF-8 form, and it throws
INVALID_ARGUMENTtoo.
Both are binary data in a string's clothes: store such values as bytes, which reach the native side unchanged. Use bytes for binary secrets such as encryption keys, rather than encoding them into a string yourself. There is no hard size limit, but the Keychain and the Keystore are made for small secrets: keep values to tens of kilobytes, and keep large data in an encrypted file whose key lives here (see A key for MMKV or SQLCipher).
Nothing stored is null
getItem, getBytes, getItemInfo and getCredentials return null when there is no item under the key. That is the only meaning of null: an item that exists but can't be read is an error with a code, such as USER_CANCELED or KEY_INVALIDATED.
removeItem of a key that isn't there does nothing and doesn't throw.
Writing replaces
setItem and setBytes replace the item under the key, value and attributes together. The write is atomic: if the new value can't be stored (for example, the device has no passcode for a passcode item), the old value stays. Writes never show a prompt.
Options by instance and by call
The options of createKeychain are the defaults of its storage; the options of a call override them for that call.
const vault = createKeychain({
service: 'vault',
accessControl: AccessControl.BiometryCurrentSet,
prompt: { title: 'Unlock your account' },
});
// This one item asks for biometrics or the passcode.
await vault.setItem('pin', pin, { accessControl: AccessControl.BiometryCurrentSetOrPasscode });
// This one read has its own prompt text.
await vault.getItem('pin', { prompt: { title: 'Confirm the payment' } });
| Options | Of | What they hold |
|---|---|---|
KeychainOptions | createKeychain | service, accessible, accessControl, authValidity, synchronizable, accessGroup, prompt, android.keyStorage |
SetOptions | setItem, setBytes, setCredentials | The protection of the item: accessible, accessControl, authValidity, synchronizable, android, and a label |
GetOptions | getItem, getBytes, getCredentials | The prompt of this read |
The protection is set when an item is written and stays with the item: a read uses the protection the item was stored with, whatever the storage's defaults are now. Protection explains every option.
Labels and item info
A label is a short string stored next to the item, outside the secret. getItemInfo reads it and the item's attributes without a prompt, even for an item behind biometrics:
await vault.setItem('refreshToken', token, { label: 'ivan@example.com' });
const info = await vault.getItemInfo('refreshToken');
// {
// key: 'refreshToken',
// label: 'ivan@example.com',
// accessible: 'whenUnlockedThisDeviceOnly',
// accessControl: 'biometryCurrentSet',
// synchronizable: false,
// createdAt: 1730000000000,
// modifiedAt: 1730000000000,
// android: { securityLevel: 'tee' },
// }
Every write sets the label: a write with label stores it, a write without one removes it. The empty string '' is a label too, different from none: getItemInfo reports label: '' for it and leaves label out when there is none, on iOS, on Android and in the Jest mock.
await vault.setItem('a', '1', { label: '' });
(await vault.getItemInfo('a'))?.label; // ''
await vault.setItem('a', '2'); // no label: the old one is removed
(await vault.getItemInfo('a'))?.label; // undefined
A label is text like a value: U+0000 in it throws INVALID_ARGUMENT. Don't put secrets in a label: it is readable without authentication by design.
Listing and clearing
These methods never show a prompt, whatever protects the items:
await vault.hasItem('refreshToken'); // true
await vault.getAllKeys(); // ['pin', 'refreshToken']: sorted
await vault.clear(); // every item of the service
clear() removes every item of the service; on Android it also deletes the service's Keystore keys.
Credentials
For a sign-in form, the package has a pair of a username and a password. The pair is one item with the key credentials, or credentials:<server> when you pass a server; the username is kept as the item's label. These are ordinary keys of the storage: getAllKeys lists them, and a setItem('credentials', …) would replace the pair.
await vault.setCredentials(
{ username: 'ivan@example.com', password },
{ accessControl: AccessControl.BiometryCurrentSet }
);
// "Sign in as ivan@example.com": no prompt.
const info = await vault.getItemInfo('credentials');
showSignInAs(info?.label);
// The password: the prompt.
const credentials = await vault.getCredentials(); // { username, password } | null
// Several accounts, by server.
await vault.setCredentials({ username, password }, { server: 'api.example.com' });
await vault.getCredentials({ server: 'api.example.com' });
await vault.removeCredentials({ server: 'api.example.com' });
Because the username is a label, the app can show who is signed in before the user passes the prompt, and ask for the password only when it is needed.
setCredentials takes no label of its own: the label is the username. The username can't be empty; the password can. Neither can hold U+0000 (INVALID_ARGUMENT). getCredentials on an item that wasn't written by setCredentials, so has no label, throws INVALID_ARGUMENT rather than make up a username.
Key names are not secret
On Android the names of the keys are stored in the clear in the service's file, inside the app's sandbox. On iOS they are the item's account attribute. Name items by what they are (refreshToken), never by what they hold: no user ids, emails or tokens in a key.