diff --git a/src/app/ClientProviders.tsx b/src/app/ClientProviders.tsx
index e1892bdc37..6bb0f37aca 100644
--- a/src/app/ClientProviders.tsx
+++ b/src/app/ClientProviders.tsx
@@ -10,6 +10,7 @@ import { ConsoleGreeting } from '@/components/Global/ConsoleGreeting'
import RainCooldownIntroModal from '@/components/Global/RainCooldown/IntroModal'
import StaleCardApprovalReEnableModal from '@/components/Global/StaleCardApproval/ReEnableModal'
import BadgeEarnToast from '@/components/Badges/BadgeEarnToast'
+import { AppLockGate } from '@/components/Global/AppLock'
import { ScreenOrientationLocker } from '@/components/Global/ScreenOrientationLocker'
import { TranslationSafeWrapper } from '@/components/Global/TranslationSafeWrapper'
import { PeanutProvider } from '@/config/peanut.config'
@@ -60,7 +61,9 @@ export function ClientProviders({ children }: { children: React.ReactNode }) {
)}
- {children}
+ {/* Wraps rather than sits beside the page: while the
+ native app is locked, nothing protected renders. */}
+ {children}
diff --git a/src/components/Global/AppLock/index.tsx b/src/components/Global/AppLock/index.tsx
new file mode 100644
index 0000000000..0d5b956140
--- /dev/null
+++ b/src/components/Global/AppLock/index.tsx
@@ -0,0 +1,165 @@
+'use client'
+
+/**
+ * Native app lock. Wraps the app on cold start and whenever it returns from
+ * more than LOCK_AFTER_BACKGROUND_MS in the background, so a session that
+ * outlives the user's attention doesn't hand the account to whoever picks the
+ * phone up next.
+ *
+ * It is a gate, not an overlay: while locked, the protected tree is not
+ * rendered at all. Nothing paints behind the lock screen and nothing back
+ * there is focusable or reachable by assistive tech. The cost is that
+ * remounting on unlock loses in-flight component state — acceptable, since the
+ * lock only fires on cold start (no state yet) or after five minutes
+ * backgrounded (where iOS has often discarded the webview anyway).
+ *
+ * Web is unaffected — there is no OS-backed presence check to lean on there,
+ * and a browser tab has no equivalent of "resumed from background".
+ */
+
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { Button } from '@/components/0_Bruddle/Button'
+import { useAuth } from '@/context/authContext'
+import { isCapacitor } from '@/utils/capacitor'
+import { getUserPreferences } from '@/utils/general.utils'
+import { LOCK_AFTER_BACKGROUND_MS, requestLocalUserPresence } from '@/utils/app-lock'
+
+/**
+ * `pending` is the state that makes this a boundary rather than a curtain: on
+ * native we enter it on the very first client paint, before the user query can
+ * resolve and render balances. Only once auth settles do we learn whether this
+ * becomes `locked` or, for a signed-out user or one with no usable credential,
+ * `open`.
+ */
+type GateState = 'open' | 'pending' | 'locked'
+
+function LockScreen({
+ failed,
+ unlocking,
+ onUnlock,
+ onLogout,
+}: {
+ failed: boolean
+ unlocking: boolean
+ onUnlock: () => void
+ onLogout: () => void
+}) {
+ return (
+
+
+
Peanut is locked
+
+ {failed ? 'Could not confirm it is you. Try again to continue.' : 'Confirm it is you to continue.'}
+
+
+
+
+
+
+
+ )
+}
+
+export function AppLockGate({ children }: { children: React.ReactNode }) {
+ const { user, isFetchingUser, logoutUser } = useAuth()
+ const userId = user?.user.userId
+ const [state, setState] = useState('open')
+ const [unlocking, setUnlocking] = useState(false)
+ const [failed, setFailed] = useState(false)
+ const backgroundedAt = useRef(null)
+
+ const credentialId = userId ? getUserPreferences(userId)?.webAuthnKey?.authenticatorId : undefined
+
+ // Close the gate on the first client paint, before anything protected can
+ // render. Runs once — later transitions are driven by resume or unlock.
+ useEffect(() => {
+ if (isCapacitor()) setState('pending')
+ }, [])
+
+ useEffect(() => {
+ if (state !== 'pending' || isFetchingUser) return
+ // Nothing to protect, or no credential we could ever prompt against —
+ // a gate we can't open would strand the user in their own app.
+ setState(userId && credentialId ? 'locked' : 'open')
+ }, [state, isFetchingUser, userId, credentialId])
+
+ const attemptUnlock = useCallback(async () => {
+ setUnlocking(true)
+ const outcome = await requestLocalUserPresence(credentialId)
+ setUnlocking(false)
+ if (outcome === 'unlocked' || outcome === 'unsupported') {
+ setFailed(false)
+ setState('open')
+ return
+ }
+ setFailed(true)
+ }, [credentialId])
+
+ useEffect(() => {
+ if (!isCapacitor() || !userId || !credentialId) return
+
+ let removeListener: (() => void) | undefined
+ let cancelled = false
+
+ import('@capacitor/app')
+ .then(({ App }) =>
+ App.addListener('appStateChange', ({ isActive }) => {
+ if (!isActive) {
+ backgroundedAt.current = Date.now()
+ return
+ }
+ const since = backgroundedAt.current
+ backgroundedAt.current = null
+ if (since !== null && Date.now() - since > LOCK_AFTER_BACKGROUND_MS) {
+ setFailed(false)
+ setState('locked')
+ }
+ })
+ )
+ .then((handle) => {
+ if (cancelled) {
+ handle.remove()
+ return
+ }
+ removeListener = () => handle.remove()
+ })
+ .catch(() => {
+ // No @capacitor/app bridge (web bundle, or an old native shell):
+ // resume-locking is unavailable. Cold-start locking still works.
+ })
+
+ return () => {
+ cancelled = true
+ removeListener?.()
+ }
+ }, [userId, credentialId])
+
+ // Prompt as soon as the gate closes, so the common case is one Face ID
+ // prompt and no taps at all.
+ useEffect(() => {
+ if (state === 'locked' && !unlocking && !failed) void attemptUnlock()
+ // Deliberately keyed on `state` alone: including attemptUnlock or the
+ // transient flags would re-fire the prompt in a loop.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [state])
+
+ if (state === 'open') return <>{children}>
+
+ // 'pending': auth hasn't settled, so we don't yet know whether to prompt.
+ // Show the bare cover rather than the "locked" copy, which would be a lie
+ // for a signed-out user about to be let straight through.
+ if (state === 'pending') return
+
+ return (
+ void attemptUnlock()}
+ onLogout={() => void logoutUser()}
+ />
+ )
+}
diff --git a/src/utils/__tests__/app-lock.test.ts b/src/utils/__tests__/app-lock.test.ts
new file mode 100644
index 0000000000..e5d8558fb2
--- /dev/null
+++ b/src/utils/__tests__/app-lock.test.ts
@@ -0,0 +1,72 @@
+/**
+ * The lock's safety property is asymmetric: failing to lock is a security
+ * weakness, but failing to UNLOCK strands the user in their own app with no
+ * way out. These tests pin which failure modes fall which way.
+ */
+import { webcrypto } from 'node:crypto'
+
+import { requestLocalUserPresence } from '../app-lock'
+import { base64URLToBytes } from '../native-webauthn'
+
+if (!globalThis.crypto?.getRandomValues) {
+ Object.defineProperty(globalThis, 'crypto', { value: webcrypto, configurable: true })
+}
+
+const CREDENTIAL_ID = 'YWJjZGVmZ2g'
+
+function withCredentialsGet(impl: () => Promise) {
+ Object.defineProperty(globalThis, 'PublicKeyCredential', { value: function () {}, configurable: true })
+ Object.defineProperty(globalThis.navigator, 'credentials', {
+ value: { get: impl },
+ configurable: true,
+ })
+}
+
+describe('requestLocalUserPresence', () => {
+ it('reports unsupported when there is no stored credential to prompt against', async () => {
+ withCredentialsGet(async () => ({}) as Credential)
+ await expect(requestLocalUserPresence(undefined)).resolves.toBe('unsupported')
+ })
+
+ it('reports unsupported when the platform has no WebAuthn', async () => {
+ Object.defineProperty(globalThis, 'PublicKeyCredential', { value: undefined, configurable: true })
+ await expect(requestLocalUserPresence(CREDENTIAL_ID)).resolves.toBe('unsupported')
+ })
+
+ it('unlocks when the authenticator returns an assertion', async () => {
+ withCredentialsGet(async () => ({}) as Credential)
+ await expect(requestLocalUserPresence(CREDENTIAL_ID)).resolves.toBe('unlocked')
+ })
+
+ it('stays locked when the user cancels the prompt', async () => {
+ withCredentialsGet(async () => {
+ throw Object.assign(new Error('cancelled'), { name: 'NotAllowedError' })
+ })
+ await expect(requestLocalUserPresence(CREDENTIAL_ID)).resolves.toBe('dismissed')
+ })
+
+ it('reports unsupported when the authenticator rejects the request outright', async () => {
+ withCredentialsGet(async () => {
+ throw Object.assign(new Error('nope'), { name: 'NotSupportedError' })
+ })
+ await expect(requestLocalUserPresence(CREDENTIAL_ID)).resolves.toBe('unsupported')
+ })
+
+ it('pins the request to the stored credential and demands user verification', async () => {
+ let received: PublicKeyCredentialRequestOptions | undefined
+ withCredentialsGet(async (options?: CredentialRequestOptions) => {
+ received = options?.publicKey
+ return {} as Credential
+ })
+ await requestLocalUserPresence(CREDENTIAL_ID)
+ expect(received?.userVerification).toBe('required')
+ expect(received?.allowCredentials).toHaveLength(1)
+ // Assert the actual bytes, not just the count: a descriptor for the
+ // WRONG credential would satisfy a length check while letting some
+ // other passkey on the device open the lock.
+ expect(Array.from(new Uint8Array(received!.allowCredentials![0].id as ArrayBuffer))).toEqual(
+ Array.from(base64URLToBytes(CREDENTIAL_ID))
+ )
+ expect(new Uint8Array(received!.challenge as ArrayBuffer)).not.toHaveLength(0)
+ })
+})
diff --git a/src/utils/app-lock.ts b/src/utils/app-lock.ts
new file mode 100644
index 0000000000..050f1750e2
--- /dev/null
+++ b/src/utils/app-lock.ts
@@ -0,0 +1,66 @@
+// Native app lock: a local user-presence gate shown when the app is opened
+// cold or resumed after a spell in the background.
+//
+// This is deliberately a LOCAL check — the assertion is never sent to the API
+// for verification. The threat it addresses is physical: someone holding an
+// already-unlocked phone. The OS will not produce an assertion without the
+// user's biometric or device passcode, which is exactly the property we want.
+// Server-side proof of a fresh assertion is a separate concern (step-up auth on
+// sensitive endpoints) and does not belong in a UI lock.
+//
+// It is a privacy screen and a deterrent, NOT account protection: the session
+// token stays where it always was (webview storage / cookie jar), reachable by
+// anything that can read the app's filesystem, and clearing the stored
+// credential id disables the gate entirely. Making it a genuine control means
+// moving the token into biometric-guarded Keychain/Keystore — tracked
+// separately.
+
+import { base64URLToBytes } from './native-webauthn'
+
+/** How long the app may sit in the background before it relocks. */
+export const LOCK_AFTER_BACKGROUND_MS = 5 * 60 * 1000
+
+export type UnlockOutcome = 'unlocked' | 'dismissed' | 'unsupported'
+
+/**
+ * Prompts for the device biometric/passcode via a WebAuthn assertion against
+ * the user's existing passkey.
+ *
+ * Returns 'unsupported' when we cannot prompt at all — no WebAuthn, no stored
+ * credential id, or the authenticator rejecting the request outright
+ * (NotSupportedError). Callers must treat that as "do not lock": a lock we
+ * can't lift would strand the user in their own app with no way back. That
+ * makes every 'unsupported' path fail OPEN — this gate is a presence check for
+ * an honest user, not a security boundary against someone who can strip the
+ * stored credential id (see the module comment).
+ */
+export async function requestLocalUserPresence(credentialId?: string): Promise {
+ if (typeof window === 'undefined') return 'unsupported'
+ if (!credentialId) return 'unsupported'
+ if (!window.PublicKeyCredential || !navigator.credentials?.get) return 'unsupported'
+
+ const challenge = new Uint8Array(32)
+ crypto.getRandomValues(challenge)
+
+ try {
+ const assertion = await navigator.credentials.get({
+ publicKey: {
+ challenge,
+ // Copy into a fresh buffer: BufferSource wants Uint8Array,
+ // and base64URLToBytes is typed over the wider ArrayBufferLike.
+ allowCredentials: [{ id: new Uint8Array(base64URLToBytes(credentialId)), type: 'public-key' }],
+ userVerification: 'required',
+ timeout: 60_000,
+ },
+ })
+ return assertion ? 'unlocked' : 'dismissed'
+ } catch (error) {
+ // A cancelled prompt and a genuinely broken authenticator are
+ // indistinguishable here; both leave the app locked with a retry, which
+ // is the safe direction. NotSupportedError is the exception: it fails
+ // open (like the pre-flight checks), since it means this device cannot
+ // complete the ceremony at all and retrying would strand the user.
+ if (error instanceof Error && error.name === 'NotSupportedError') return 'unsupported'
+ return 'dismissed'
+ }
+}