After a user adds a new fingerprint, an Expo app's biometric-protected expo-secure-store value reads as null; why, and how should the app recover?
answer
- bound to the enrolled set
- resolves null, not an error
- Android: key permanently invalidated
- re-authenticate another way, re-save
- shared service widens the loss
basics
~20 sValues saved with requireAuthentication: true are bound to the currently enrolled biometrics, so enrolling a new fingerprint invalidates them and getItemAsync resolves null. Treat that as expired: sign the user in another way, then save a fresh value.
solid answer
~40 sOn iOS the item's access control uses `biometryCurrentSet`, and on Android the Keystore key requires user authentication; both are invalidated when the enrolled biometrics change, for example a new fingerprint or a changed face profile. expo-secure-store then resolves `getItemAsync` with `null` rather than rejecting; on Android it catches the permanently-invalidated key and returns `null`. The value cannot be recovered. The app should treat `null` from a protected key as needing re-authentication with the password or server, not as a first run, then save a new value. On Android, the next save deletes the invalidated key and also removes every authenticated entry under the same `keychainService`, so keep protected items under their own service. Avoid making a biometric-protected value the only copy of anything; it should be a secret the server can reissue.
code
typescript · 17 linesimport * as SecureStore from 'expo-secure-store';
const PROTECTED: SecureStore.SecureStoreOptions = {
keychainService: 'unlock-auth',
requireAuthentication: true,
authenticationPrompt: 'Unlock price alerts',
};
const MARKER_KEY = 'biometric_unlock_enabled'; // not protected, default service
type UnlockResult = { kind: 'token'; token: string } | { kind: 'reauthenticate' } | { kind: 'not-enabled' };
export async function unlock(): Promise<UnlockResult> {
const enabled = (await SecureStore.getItemAsync(MARKER_KEY)) === 'true';
if (!enabled) return { kind: 'not-enabled' };
const token = await SecureStore.getItemAsync('refresh_token', PROTECTED); // rejects if the prompt is cancelled
return token === null ? { kind: 'reauthenticate' } : { kind: 'token', token };
}go deeper
Recall that values saved with requireAuthentication become unreadable when biometrics change, and that getItemAsync then resolves null.
Explain the binding to the enrolled biometric set on both platforms and why a null needs a marker to be told apart from first use.
Build the recovery path: re-authenticate, re-save, keep protected items under a dedicated keychainService, and never tie irreplaceable data to them.
Decide which secrets may depend on biometric state and what the account-recovery story is when the device's biometrics change.
## The scenario A crypto-price tracker offers "unlock with Face ID or fingerprint": it keeps a refresh token in expo-secure-store with `requireAuthentication: true`. A user adds a second fingerprint in system settings. The next time they open the app and authenticate, the protected value reads as `null`, and a naive app treats that as "not signed in", or worse, as a fresh install and wipes local data. ## Why the value disappears Both platforms bind the protection to the **current set** of enrolled biometrics: - **iOS**: expo-secure-store saves the item with access control created with `biometryCurrentSet`. When the enrolled set changes, items protected that way can no longer be released. - **Android**: the value is encrypted with a Keystore key generated with user authentication required. When biometrics change, the system invalidates that key permanently. This is deliberate security behaviour: someone who learns the passcode and enrols their own finger must not gain access to secrets that were protected by the owner's biometrics. The library documents the outcome: keys are invalidated when biometrics change, after which it is impossible to read the value, and **`getItemAsync` resolves `null`** for an invalidated key. On Android, the source catches the permanently-invalidated-key exception, logs a warning and returns `null`, so the promise does not reject. ## What happens on the next save (Android) Writing a new value after invalidation is also handled in the Android source: 1. The first attempt fails because the key is invalidated. 2. The module deletes that Keystore key and removes **all authenticated entries stored under the same `keychainService`**, since they were encrypted with the deleted key. 3. It retries with a newly generated key. Entries that do not require authentication use separate keys and are not deleted. The practical consequence is scope: every protected item sharing a `keychainService` is lost together. Group protected items by lifetime, and keep them apart from unprotected ones. ## Recovering properly | Situation at launch | What `null` means | What to do | |---|---|---| | No record that a protected value was ever saved | first use | offer to enable biometric unlock | | A flag says it was saved, value is `null` | invalidated (or removed) | re-authenticate with password or server, then save again | | The call rejects | prompt cancelled or failed | offer retry; do not sign out | To tell the first two apart, keep a small non-secret marker ("biometric unlock enabled") outside SecureStore's protected entries. Then: - On `null` with the marker set, explain that biometric settings changed and ask the user to sign in again. - After a successful sign-in, save a new protected value; it binds to the new enrolled set. - Never delete unrelated local data because of this `null`. ## Explaining it to the user The user did nothing wrong, and a generic "session expired" message confuses them. A clear message names the cause: biometric settings changed on this device, so biometric unlock must be set up again after signing in. Offer the sign-in form directly, and re-offer biometric unlock right after it succeeds. Count how often the invalidation branch runs, using the app's own logging; a sudden rise after a release usually means a code path is misreading `null`, not that users changed fingerprints. ## Designing so invalidation is harmless - **Protect reissuable secrets only.** A refresh token the server can issue again is a good candidate. A locally generated encryption key for user data is a dangerous one: if it is lost, the encrypted data is gone with it, unless there is another way to recover the key. - **Keep a second path in.** Biometric unlock should be a convenience over a primary sign-in method, never the only way into the account. - **Test the path on hardware.** Enrol a new fingerprint on a real device and confirm the app lands in the re-authentication flow; simulators do not exercise the biometric gate.
- Why does expo-secure-store resolve null instead of rejecting for an invalidated key?The value is permanently unreadable, which is closer to 'no entry' than to a transient failure. Resolving `null` lets the app take its normal signed-out path, while rejections are kept for recoverable problems such as a cancelled prompt.
- Is it safe to protect a locally generated database encryption key with requireAuthentication?Only if there is another way to recover it. A biometric change makes the key unreadable, and data encrypted with it is then lost. Protect reissuable secrets such as refresh tokens, or keep a recovery path such as a server-held backup of the key.
saying these in an interview costs you the question
- A new fingerprint just triggers an extra prompt; the value stays readable
- getItemAsync rejects with an error when the key is invalidated
- A null from a protected key always means a fresh install
- The invalidated value can be recovered by authenticating with the device passcode
- On Android, invalidation affects only the single entry that was read