"Can we lock the app behind Face ID?" comes up in nearly every finance, health and enterprise build we do. Adding a biometric prompt is ten lines of code. Making it a good app lock is harder. It has to know when to lock, avoid flashing sensitive content in the app switcher, fall back sensibly, and never pretend that a local check is server-side authentication. This tutorial builds an app lock for an Ionic + Capacitor app. The examples use Ionic React, and the pattern is the same for Angular and Vue.
What a biometric lock is (and is not)
A biometric prompt proves that the person holding the phone is the person who enrolled on the device. It does not prove anything to your server. Treat it as a gate in front of a session you already hold. That means a refresh token in secure storage, plus a local decision to reveal the UI. If the server must know the user re-authenticated (moving money, changing a password), do a real step-up authentication. Do not rely on a flag set in the client.
1. Install
We use community plugins for the biometric prompt and for Keychain/Keystore-backed storage. Any maintained plugin with the same capabilities works; check that it supports your Capacitor major version before adopting it.
npm i @aparajita/capacitor-biometric-auth @aparajita/capacitor-secure-storage
npx cap sync
On iOS add NSFaceIDUsageDescription to Info.plist with a plain-language reason. Without it, Face ID fails on device and App Review will ask why.
2. Check what the device can do
Never assume biometrics exist. The user may have none enrolled, may have disabled them for your app, or may be on an emulator.
import { BiometricAuth } from '@aparajita/capacitor-biometric-auth';
export async function lockCapability() {
const info = await BiometricAuth.checkBiometry();
return {
biometrics: info.isAvailable, // enrolled and usable
deviceCredential: info.deviceIsSecure, // passcode / PIN is set
};
}
Decide your policy from this. A reasonable default: use biometrics when available, allow the device passcode as a fallback, and offer no lock at all if the device has no screen lock. In that case, say so in settings rather than failing silently.
3. Prompt for unlock
export async function unlock(): Promise<boolean> {
try {
await BiometricAuth.authenticate({
reason: 'Unlock your account',
cancelTitle: 'Cancel',
allowDeviceCredential: true,
androidTitle: 'Unlock',
});
return true;
} catch {
return false; // cancelled, failed, or locked out: stay locked
}
}
Treat every error as "stay locked". Distinguish the cases only to choose the message. A user cancelling should see a quiet "Unlock" button. A lockout should explain that the device passcode is needed.
4. Decide when to lock
The lock should engage when the app goes to the background and the user has been away longer than a grace period (30 to 60 seconds suits most apps; regulated apps use zero). Use the App plugin and keep the timestamp in memory only:
import { App } from '@capacitor/app';
let backgroundedAt: number | null = null;
const GRACE_MS = 30_000;
export function watchLifecycle(setLocked: (v: boolean) => void) {
App.addListener('appStateChange', ({ isActive }) => {
if (!isActive) {
backgroundedAt = Date.now();
} else if (backgroundedAt && Date.now() - backgroundedAt > GRACE_MS) {
setLocked(true);
}
});
setLocked(true); // always start locked on cold start
}
Keeping the timestamp in memory is deliberate. If the OS kills the app, the next launch is a cold start and starts locked anyway. A persisted "unlocked until" value is just something an attacker can edit.
5. Render the lock above everything
Mount the lock screen at the app root, outside the router, so no page content renders underneath it. Do not just hide pages with CSS.
export function AppRoot() {
const [locked, setLocked] = useState(true);
useEffect(() => { watchLifecycle(setLocked); }, []);
if (locked) {
return <LockScreen onUnlock={async () => { if (await unlock()) setLocked(false); }} />;
}
return <IonReactRouter>{/* routes */}</IonReactRouter>;
}
Unmounting the router on lock also drops in-memory data tied to those pages. Call unlock() once automatically when LockScreen mounts, and keep a visible button for retries.
6. Hide content in the app switcher
The OS takes a screenshot when the app backgrounds, and it shows in the task switcher. Because appStateChange can fire after that snapshot, rely on a privacy overlay as well as the lock. On Android, set FLAG_SECURE on the window for sensitive screens (a small native tweak in MainActivity, or a maintained privacy-screen plugin). On iOS, a plugin that covers the window on applicationWillResignActive does the same job. Test by opening the app switcher and checking that the card shows the overlay and not the account balance.
7. Keep the session token in secure storage
import { SecureStorage } from '@aparajita/capacitor-secure-storage';
await SecureStorage.set('refresh_token', token);
const t = await SecureStorage.get('refresh_token');
Secure storage keeps the token in the Keychain or Keystore, not in localStorage. Whether the token itself requires a biometric to read is a separate, stronger option. It binds the secret to the enrolled biometric set in hardware. The trade-off is that adding a fingerprint can invalidate the key, so you need a re-login path. For most apps the UI gate above plus Keychain/Keystore storage is the right balance.
8. Edge cases that bite in production
- Biometrics changed or removed. If the user disables biometrics in system settings, your next
checkBiometryreports it. Fall back to the passcode or force a full login. Do not leave the app unlocked. - Permission prompts. Camera, location and the system share sheet send the app to
inactive/background. Without handling this, your own flows trigger the lock. Set a short "expected backgrounding" flag around those calls. - Deep links and push taps. A cold start from a notification still starts locked. Store the target route in memory and navigate after unlock.
- Logout. Clear the token from secure storage and reset lock state. The lock is not a substitute for sign-out.
- Accessibility. Keep the manual "Unlock" button reachable with screen readers. Do not depend on the system prompt alone.
9. Test it
You can drive both iOS Simulator (Features → Face ID → Matching/Non-matching Face) and Android emulator (extended controls → Fingerprint) through the success, failure and lockout paths. Unit-test the lock policy (grace period, cold start, capability fallback) as plain functions. Verify the app-switcher overlay on a real device before release.
Checklist
NSFaceIDUsageDescriptionpresent; capability check handles "no biometrics"- Cold start is always locked; grace period is configurable
- Lock rendered above the router, not CSS-hidden
- App-switcher snapshot is masked on both platforms
- Tokens in Keychain/Keystore; sensitive actions use server-side step-up
- Biometric changes, permission prompts and deep links tested
If you need an app lock that satisfies a security review, we build it as part of our Capacitor consulting and plugin development work. Talk to us about your app.