Skip to content
Skip to content
Goodspeed

BUILT INTO EVERY GOODSPEED APP

Biometric Lock (Face ID / Touch ID)

A configurable re-authentication timeout gates the app behind Face ID or Touch ID on every foreground resume, with no extra backend calls. The check is local and completes in under 200ms.

  • Tier: Common
  • Status: Config-toggled
  • Config: features.auth.biometric.enabled

WHY IT MATTERS

Most mobile apps that store sensitive data protect sessions with a password or OAuth token but leave the device-level re-authentication problem entirely up to the user. If someone picks up an unlocked phone, they can open the app and read everything without being challenged again. Biometric Lock closes that gap by storing the last-authenticated timestamp in expo-secure-store (the iOS Keychain or Android Keystore, not AsyncStorage) and checking it against a configurable timeout on every foreground resume. The check is fully local: no network request, no Supabase round-trip, no latency spike. It resolves in under 200ms because the hardware security module does all the cryptographic work. The result is a second factor that does not require the user to type anything, and that cannot be bypassed by rotating credentials on a remote server.

The implementation in services/biometric.ts is built around three functions that compose cleanly. isBiometricAvailable() is the guard: it checks the gasConfig flag, the platform (web returns false immediately), hardware capability, and enrollment status before ever showing a prompt. authenticate() calls expo-local-authentication's authenticateAsync with a configurable prompt message and a Use passcode fallback for users whose Face ID or Touch ID is temporarily unavailable due to wet fingers, face occlusion, or hardware failure. requiresReauth() computes the staleness of the last authentication against the timeoutMinutes policy from gasConfig. App shell code invokes requiresReauth() on AppState foreground transitions and redirects to the biometric gate screen when it returns true. Setting features.auth.biometric.enabled to false in gas.config.ts removes all code paths with no residual imports or SecureStore reads in the production bundle.

HOW IT IS WIRED

Real code from the GAS template

The code below is drawn from services/biometric.ts in the gas-template repository. This is the code your generated app gets, not pseudocode, not a description of intent.

// services/biometric.ts
import * as LocalAuthentication from 'expo-local-authentication';
import * as SecureStore from 'expo-secure-store';
import { Platform } from 'react-native';
import { gasConfig } from '../gas.config';
import { ServiceError } from './errors';

const LAST_BIOMETRIC_KEY = 'gas:lastBiometricAt';

export async function isBiometricAvailable(): Promise<boolean> {
  if (!gasConfig.features.auth.biometric.enabled) return false;
  if (Platform.OS === 'web') return false;
  const compatible = await LocalAuthentication.hasHardwareAsync();
  if (!compatible) return false;
  const enrolled = await LocalAuthentication.isEnrolledAsync();
  return enrolled;
}

export async function authenticate(reason = 'Authenticate to continue'): Promise<boolean> {
  if (!(await isBiometricAvailable())) {
    throw new ServiceError('biometric_unavailable', 400, 'Biometric authentication is not available');
  }
  const result = await LocalAuthentication.authenticateAsync({
    promptMessage: reason,
    fallbackLabel: 'Use passcode',
  });
  if (result.success) {
    await SecureStore.setItemAsync(LAST_BIOMETRIC_KEY, String(Date.now()));
    return true;
  }
  return false;
}

export async function requiresReauth(): Promise<boolean> {
  if (!gasConfig.features.auth.biometric.enabled) return false;
  const last = await SecureStore.getItemAsync(LAST_BIOMETRIC_KEY);
  if (!last) return true;
  const timeoutMs = gasConfig.features.auth.biometric.timeoutMinutes * 60_000;
  return Date.now() - Number(last) > timeoutMs;
}

Source: goodspeed-apps/gas-template services/biometric.ts

HONEST LIMITS

When Biometric Lock (Face ID / Touch ID) is the wrong choice

Biometric lock is designed for single-owner devices. The pattern it implements is: one biometric identity per device unlock session. When a device is shared between multiple users who each have their own accounts in your app, Face ID or Touch ID will authenticate as whoever enrolled their face or fingerprint with the device, not necessarily as the person currently using the app. That means User A can pick up User B's device and pass the biometric gate using their own enrolled biometric on User B's device. This is an OS-level behavior that the template cannot prevent. Skip biometric lock for shared-device apps such as field tablets, family iPads, or enterprise shared phones. Replace the lock with a PIN or password screen instead, so the gate is tied to knowledge rather than hardware enrollment. Also avoid biometric lock for apps where the threat model centers on remote compromise rather than physical access. If an attacker already has valid session tokens from a leak or a phishing attack, they are operating server-side and the biometric prompt never fires. Biometric lock is a physical-access mitigation: it protects against someone picking up an unlocked device. It adds no protection against stolen tokens, session hijacking, or server-side data access. For those threat models, invest in short-lived JWTs, token rotation, and Supabase RLS policies instead.

Tier: Common · Config-toggled

  1. Evaluate your use case

    Check whether biometric lock (face id / touch id) aligns with your target audience, platform constraints, and regulatory environment before enabling it.

  2. Audit the config

    The `features.auth.biometric.enabled` flag controls this feature. Set it to false in gas.config.ts to disable the feature entirely with no residual code paths.

  3. Seek alternatives

    If the built-in implementation does not fit, the generated codebase is standard React Native + Expo code. Any library in the Expo ecosystem can replace the default.

APPS USING THIS FEATURE

Every generated Goodspeed app includes biometric lock (face id / touch id). Browse the ideas catalog to see apps across all categories that ship with this feature wired in.

CAPABILITIES

Biometric Lock (Face ID / Touch ID) capability breakdown

Concrete dimensions of what the built-in biometric lock (face id / touch id) implementation covers. These reflect the actual template code, not a marketing summary.

ItemDescriptionStrength
Hardware checkisBiometricAvailable() calls hasHardwareAsync() then isEnrolledAsync() before ever invoking the prompt. No prompt fires on unsupported or unenrolled devices.Runtime-detected
Re-auth timeoutThe last-authenticated timestamp is read from expo-secure-store. requiresReauth() compares it against gasConfig.features.auth.biometric.timeoutMinutes on every foreground resume.Config-driven
Token storageThe timestamp is written to SecureStore (iOS Keychain / Android Keystore). No plaintext credentials are stored. Only your app bundle ID can read the value.SecureStore
Passcode fallbackauthenticateAsync is called with fallbackLabel set to Use passcode. Users on devices without biometric enrollment reach the OS passcode screen through the same call.Built in
Web supportisBiometricAvailable() returns false on Platform.OS web. All biometric calls are no-ops in browser environments with zero errors or broken imports.No-op on web

COMMON QUESTIONS

Does biometric lock work on iOS Simulator and Android Emulator?

Simulators and emulators can simulate biometric prompts, but the results are not reliable for production testing. expo-local-authentication's hasHardwareAsync() returns false on most simulators, so isBiometricAvailable() correctly returns false and no prompt fires. To test the full lock-and-unlock flow, use a physical device. The test suite in __tests__/services/biometric.test.ts mocks LocalAuthentication at the module boundary, so unit tests run fine in CI without hardware. The mock covers three branches: hardware present with enrollment, hardware present without enrollment, and hardware absent entirely. All three branches are exercised in the existing test file.

What happens if the user denies the biometric prompt or has no biometric enrolled?

authenticateAsync is called with fallbackLabel set to Use passcode. If the user cancels or dismisses the prompt, the function returns false rather than throwing an exception. The calling screen decides what to do next. The template default behavior is to keep the lock screen active and let the user try again or tap Use passcode. The passcode fallback invokes the iOS system passcode or Android lock screen through the same authenticateAsync call, so the user always has a path through the gate. There is no way for a caller to bypass the lock by catching an exception, because a dismissed prompt returns false rather than an error code that could be confused with success.

Is the biometric timestamp stored securely? Could another app read it?

expo-secure-store writes to the iOS Keychain with kSecAttrAccessibleWhenUnlockedThisDeviceOnly and to the Android Keystore with per-app isolation. The value stored under gas:lastBiometricAt is readable only by your app bundle ID. The timestamp itself reveals nothing sensitive: it is a Unix millisecond integer used to compute whether re-auth has expired. Even if an attacker read it out of band, the only information they would obtain is when the user last authenticated. They could not replay the timestamp to bypass a future prompt, because the check is performed inside the app process using the live system clock against the stored value at the moment the gate runs.

How do I configure the re-authentication timeout?

Set gasConfig.features.auth.biometric.timeoutMinutes in gas.config.ts to any positive integer. A value of 5 means the user is prompted again if more than five minutes have passed since their last successful authentication. A value of 0 means every foreground resume triggers a prompt regardless of how recently the user authenticated. There is no maximum enforced by the template, but timeouts above half an hour substantially reduce the protective value of the lock for sensitive-data apps. The default in the template is five minutes, balancing friction against protection for apps with moderately sensitive data. Finance and health apps typically use one to two minutes to match the expectations users have from banking apps on iOS and Android.

GET IT BUILT INTO YOUR APP

Score your idea and get biometric lock (face id / touch id) wired in from day one