MobileApp lock & biometrics

App lock & biometrics

A banking-style lock that gates the authenticated app behind a biometric unlock (Face ID / Touch ID / fingerprint). Native-only; on web this whole layer is a passthrough.

Why biometrics, not a WebAuthn passkey, on native

Real WebAuthn passkeys cannot run inside the Capacitor WebView: the origin is https://localhost, not velon.finance (no associated-domains entitlement), so navigator.credentials.get() fails. The native equivalent is a device-owner biometric check via the local Biometric plugin (iOS LAContext, Android BiometricPrompt), which unlocks a session that is already authenticated.

On web, the real WebAuthn passkey login flow is unchanged and remains the canonical “passkey”.

Lock triggers

TriggerBehavior
Cold startIf a session exists and biometric is available → app opens locked.
Background → foregroundLocks after a short grace (~1.5s) so OS sheets (the Face ID prompt itself, share sheet) don’t self-lock.
IdleLocks after ~2 min of no interaction while foregrounded.

The grace window matters: presenting the biometric prompt can briefly background the WebView. Without it the app would lock itself in a loop. While a lock is shown or an unlock is in flight, background transitions are ignored.

Safety rule: never trap the user

The lock only arms when the device actually has a biometric/credential enrolled. If none exists, the app does not lock (otherwise every background would force a full logout). The unlock screen always offers “Entrar com senha” → logout → /login as an escape hatch.

Lock screen anatomy

┌─────────────────────────────┐
│                             │
│        [ Velon logo ]      │   white wordmark on velon-navy
│                             │
│        App bloqueado         │
│   Use sua biometria para     │
│   desbloquear e continuar.   │
│                             │
│           ( 👆 )             │   fingerprint button → biometric prompt
│         Desbloquear          │
│                             │
│       Entrar com senha       │   → logout → /login
└─────────────────────────────┘
  • Full-screen fixed inset-0 z-[100] overlay on bg-velon-navy, padded with both safe-area insets. It must cover the bottom nav (hence the high z-index).
  • A failed attempt shows an inline error; a cancelled attempt does not (the user chose to dismiss).

State model

Lock state is pure and client-only (unit-tested). Only the user’s enable preference persists across restarts. isLocked, backgroundedAt and the in-flight flag are runtime.

  • isLockEffectivelyEnabled(pref, available)available && (pref ?? true): default-on when a biometric exists, overridable by an explicit user preference.
  • shouldLockOnForeground(backgroundedAt, now)now - backgroundedAt >= grace.

No backend is involved: the biometric check is local and unlock only flips client state. Logout reuses the existing session-logout endpoint.

Logout

The app exposes a Sair row at the bottom of the mobile Profile menu (destructive styling). It calls the auth store logout, then hard-redirects to /login (with a local-storage clear + Supabase sign-out fallback) so all in-memory and persisted state is dropped.

Enrollment (planned)

After the first OTP login on native, prompt once: “Ativar desbloqueio por biometria?”, plus a toggle under Segurança to opt out. Until then the core defaults to on whenever a biometric is available. Tracked in Velon-user#545.