skip to content

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?

level: seniorimportance: should knowfreq 18%

answer

  1. bound to the enrolled set
  2. resolves null, not an error
  3. Android: key permanently invalidated
  4. re-authenticate another way, re-save
  5. shared service widens the loss

basics

~20 s

Values 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 s

On 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 lines
typescript
import * 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

for a junior

Recall that values saved with requireAuthentication become unreadable when biometrics change, and that getItemAsync then resolves null.

for a middle

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.

for a senior

Build the recovery path: re-authenticate, re-save, keep protected items under a dedicated keychainService, and never tie irreplaceable data to them.

for a principal

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