Local auth
@symbiote-native/local-auth wraps
expo-local-authentication
— hasHardwareAsync, isEnrolledAsync, getEnrolledLevelAsync,
supportedAuthenticationTypesAsync, authenticateAsync, cancelAuthenticate — so every
SymbioteNative adapter can drive it. Like sensors, it’s built on
expo-modules-core; but unlike sensors’ EventEmitter + live-subscription surface, every
function here is a one-shot async call with no per-instance state — the same imperative shape as
splash screen’s hide()/isVisible(). What sets it apart from
both: authenticateAsync resolves a discriminated ILocalAuthenticationResult success/error
union rather than a plain boolean, and several ILocalAuthenticationOptions fields only apply on
one platform (promptSubtitle, biometricsSecurityLevel — Android only; fallbackLabel — iOS
only) — worth knowing before you reach for one.
| OS platform | Support |
|---|---|
| iOS | ✅ live |
| Android | ✅ live |
| Framework adapter | Support |
|---|---|
| React | ✅ live |
| Vue | ✅ live |
| Angular | ✅ live |
| Svelte | ✅ live |
| Solid | ✅ live |
Installation
Section titled “Installation”pnpm add @symbiote-native/local-authScaffolding or extending a SymbioteNative app? npx @symbiote-native/cli new --local-auth (or
add --local-auth in an existing app) installs and wires this for you — see
@symbiote-native/cli.
expo-local-authentication and expo-modules-core come along as regular dependencies, pinned to
exact versions — never install either yourself, and never add the expo meta-package to your
project (it bundles its own Metro/Babel pipeline, which conflicts with this project’s own).
All five adapters — React, Vue, Angular, Svelte, Solid — re-export the exact same free functions;
there is no per-adapter hook/composable/service to reach for, since nothing here holds live state
or a subscription. Probe capabilities once on mount, then call authenticateAsync from a button
press and branch on result.success.
import { useEffect, useState } from 'react';import { authenticateAsync, getEnrolledLevelAsync, hasHardwareAsync, isEnrolledAsync, supportedAuthenticationTypesAsync,} from '@symbiote-native/local-auth/react';import type { ILocalAuthenticationResult } from '@symbiote-native/local-auth/react';
export default function LocalAuthGate() { const [hasHardware, setHasHardware] = useState(false); const [isEnrolled, setIsEnrolled] = useState(false); const [result, setResult] = useState<ILocalAuthenticationResult | null>(null);
useEffect(() => { hasHardwareAsync().then(setHasHardware); isEnrolledAsync().then(setIsEnrolled); getEnrolledLevelAsync().then(level => console.log('enrolled level', level)); supportedAuthenticationTypesAsync().then(types => console.log('supported types', types)); }, []);
const handleAuthenticate = () => { authenticateAsync({ promptMessage: 'Confirm it is you' }).then(setResult); };
return ( <view> <text>{hasHardware && isEnrolled ? 'Ready to authenticate' : 'No biometrics enrolled'}</text> <pressable onPress={handleAuthenticate}> <text>Authenticate</text> </pressable> {result && <text>{result.success ? 'Success' : `Failed: ${result.error}`}</text>} </view> );}<script setup lang="ts">import { onMounted, ref } from 'vue';import { authenticateAsync, getEnrolledLevelAsync, hasHardwareAsync, isEnrolledAsync, supportedAuthenticationTypesAsync,} from '@symbiote-native/local-auth/vue';import type { ILocalAuthenticationResult } from '@symbiote-native/local-auth/vue';
const hasHardware = ref(false);const isEnrolled = ref(false);const result = ref<ILocalAuthenticationResult | null>(null);
onMounted(() => { void hasHardwareAsync().then(value => (hasHardware.value = value)); void isEnrolledAsync().then(value => (isEnrolled.value = value)); void getEnrolledLevelAsync().then(level => console.log('enrolled level', level)); void supportedAuthenticationTypesAsync().then(types => console.log('supported types', types));});
function handleAuthenticate(): void { void authenticateAsync({ promptMessage: 'Confirm it is you' }).then(value => { result.value = value; });}</script>
<template> <view> <text>{{ hasHardware && isEnrolled ? 'Ready to authenticate' : 'No biometrics enrolled' }}</text> <pressable @press="handleAuthenticate"> <text>Authenticate</text> </pressable> <text v-if="result">{{ result.success ? 'Success' : `Failed: ${result.error}` }}</text> </view></template>import { Component, signal } from '@angular/core';import { SYMBIOTE_ELEMENTS } from '@symbiote-native/angular';import { authenticateAsync, getEnrolledLevelAsync, hasHardwareAsync, isEnrolledAsync, supportedAuthenticationTypesAsync,} from '@symbiote-native/local-auth/angular';import type { ILocalAuthenticationResult } from '@symbiote-native/local-auth/angular';
@Component({ standalone: true, imports: [SYMBIOTE_ELEMENTS], template: ` <view> <text>{{ hasHardware() && isEnrolled() ? 'Ready to authenticate' : 'No biometrics enrolled' }}</text> <pressable (press)="handleAuthenticate()"> <text>Authenticate</text> </pressable> @if (result(); as value) { <text>{{ value.success ? 'Success' : 'Failed: ' + value.error }}</text> } </view> `,})export class LocalAuthGate { readonly hasHardware = signal(false); readonly isEnrolled = signal(false); readonly result = signal<ILocalAuthenticationResult | null>(null);
constructor() { hasHardwareAsync().then(value => this.hasHardware.set(value)); isEnrolledAsync().then(value => this.isEnrolled.set(value)); getEnrolledLevelAsync().then(level => console.log('enrolled level', level)); supportedAuthenticationTypesAsync().then(types => console.log('supported types', types)); }
handleAuthenticate(): void { authenticateAsync({ promptMessage: 'Confirm it is you' }).then(value => this.result.set(value)); }}There’s no per-instance service to inject() here — every function is a plain free function
off the core package, called straight from the constructor, same as the real
examples/expo-angular LocalAuthScreen.
<script lang="ts"> import { authenticateAsync, getEnrolledLevelAsync, hasHardwareAsync, isEnrolledAsync, supportedAuthenticationTypesAsync, } from '@symbiote-native/local-auth/svelte'; import type { ILocalAuthenticationResult } from '@symbiote-native/local-auth/svelte';
let hasHardware = $state(false); let isEnrolled = $state(false); let result = $state<ILocalAuthenticationResult | null>(null);
$effect(() => { hasHardwareAsync().then(value => (hasHardware = value)); isEnrolledAsync().then(value => (isEnrolled = value)); getEnrolledLevelAsync().then(level => console.log('enrolled level', level)); supportedAuthenticationTypesAsync().then(types => console.log('supported types', types)); });
function handleAuthenticate(): void { authenticateAsync({ promptMessage: 'Confirm it is you' }).then(value => (result = value)); }</script>
<view> <text>{hasHardware && isEnrolled ? 'Ready to authenticate' : 'No biometrics enrolled'}</text> <pressable onPress={handleAuthenticate}> <text>Authenticate</text> </pressable> {#if result} <text>{result.success ? 'Success' : `Failed: ${result.error}`}</text> {/if}</view>import { createSignal, onMount } from 'solid-js';import { authenticateAsync, getEnrolledLevelAsync, hasHardwareAsync, isEnrolledAsync, supportedAuthenticationTypesAsync,} from '@symbiote-native/local-auth/solid';import type { ILocalAuthenticationResult } from '@symbiote-native/local-auth/solid';
export default function LocalAuthGate() { const [hasHardware, setHasHardware] = createSignal(false); const [isEnrolled, setIsEnrolled] = createSignal(false); const [result, setResult] = createSignal<ILocalAuthenticationResult | null>(null);
onMount(() => { hasHardwareAsync().then(setHasHardware); isEnrolledAsync().then(setIsEnrolled); getEnrolledLevelAsync().then(level => console.log('enrolled level', level)); supportedAuthenticationTypesAsync().then(types => console.log('supported types', types)); });
const handleAuthenticate = () => { authenticateAsync({ promptMessage: 'Confirm it is you' }).then(setResult); };
return ( <view> <text>{hasHardware() && isEnrolled() ? 'Ready to authenticate' : 'No biometrics enrolled'}</text> <pressable onPress={handleAuthenticate}> <text>Authenticate</text> </pressable> {result() && <text>{result()!.success ? 'Success' : `Failed: ${result()!.error}`}</text>} </view> );}Functions
Section titled “Functions”| Signature | Description |
|---|---|
hasHardwareAsync(): Promise<boolean> |
Determine whether a face or fingerprint scanner is available on the device |
supportedAuthenticationTypesAsync(): Promise<AuthenticationType[]> |
Determine what kinds of authentication are available on the device — a device can support several ([FINGERPRINT, FACIAL_RECOGNITION]), and an empty array means none |
isEnrolledAsync(): Promise<boolean> |
Determine whether the device has saved fingerprints or facial data to use for authentication |
getEnrolledLevelAsync(): Promise<SecurityLevel> |
Determine what kind of authentication is enrolled on the device — on pre-M Android devices this can read SECRET if only the SIM lock is enrolled, which authenticateAsync doesn’t actually prompt |
authenticateAsync(options?: ILocalAuthenticationOptions): Promise<ILocalAuthenticationResult> |
Attempts to authenticate via Fingerprint/TouchID, or FaceID where available. symbiote-expo-link puts a default NSFaceIDUsageDescription into Info.plist; if that key is missing, iOS falls back to the device passcode instead of throwing |
cancelAuthenticate(): Promise<void> |
Cancels an in-flight authentication flow. @platform android |
ILocalAuthenticationOptions
Section titled “ILocalAuthenticationOptions”| Field | Type | Default | Description |
|---|---|---|---|
promptMessage |
string |
'Authenticate' |
A message shown alongside the TouchID or FaceID prompt |
promptSubtitle |
string |
— | A subtitle displayed below the prompt message. @platform android |
promptDescription |
string |
— | A description displayed in the middle of the authentication prompt. @platform android |
cancelLabel |
string |
'Cancel' |
Customizes the default Cancel label shown |
disableDeviceFallback |
boolean |
false |
After several failed attempts the system normally falls back to the device passcode; set true to disable that and handle the fallback yourself |
requireConfirmation |
boolean |
true |
Hints to the system whether it should require explicit user confirmation after a successful biometric read. @platform android |
biometricsSecurityLevel |
'weak' | 'strong' |
'weak' |
The biometric class to allow — 'strong' accepts only Android Class 3 biometrics, 'weak' accepts both Class 3 and Class 2. @platform android |
fallbackLabel |
string |
— | Customizes the default Use Passcode label shown after several failed attempts; an empty string hides the button entirely. @platform ios |
ILocalAuthenticationResult
Section titled “ILocalAuthenticationResult”A discriminated union — always check success before reading error:
| Branch | Fields | Description |
|---|---|---|
{ success: true } |
none | Authentication succeeded — no further fields |
{ success: false } |
error: ILocalAuthenticationError, warning?: string |
Authentication failed or couldn’t run; error is the machine-readable reason (see below), warning is an optional free-text detail some Android failures attach (e.g. KeyguardManager#isDeviceSecure() returned false) |
AuthenticationType
Section titled “AuthenticationType”| Field | Value | Description |
|---|---|---|
FINGERPRINT |
1 |
Fingerprint support |
FACIAL_RECOGNITION |
2 |
Facial recognition support |
IRIS |
3 |
Iris recognition support. @platform android |
SecurityLevel
Section titled “SecurityLevel”| Field | Value | Description |
|---|---|---|
NONE |
0 |
No enrolled authentication of any kind |
SECRET |
1 |
Non-biometric authentication enrolled (PIN, pattern, or password) |
BIOMETRIC_WEAK |
2 |
Weak biometric authentication enrolled — e.g. 2D image-based face unlock; there are currently no weak options on iOS |
BIOMETRIC_STRONG |
3 |
Strong biometric authentication enrolled — e.g. a fingerprint scan or 3D face unlock |
BIOMETRIC (deprecated) |
aliases BIOMETRIC_STRONG/BIOMETRIC_WEAK |
A getter kept for upstream compatibility that resolves to the platform-correct strong/weak member and logs a deprecation warning on every read — use BIOMETRIC_WEAK/BIOMETRIC_STRONG directly instead |
ILocalAuthenticationError
Section titled “ILocalAuthenticationError”| Value | Description |
|---|---|
not_enrolled |
No PIN/pattern/password or biometric is enrolled on the device at all |
user_cancel |
The user dismissed the authentication prompt themselves |
app_cancel |
The app canceled the authentication flow (e.g. via cancelAuthenticate()) |
not_available |
Authentication isn’t available on this device right now |
lockout |
Too many failed attempts — biometric authentication is temporarily locked out |
no_space |
Not enough storage on the device to complete the operation |
timeout |
The authentication attempt timed out |
unable_to_process |
The system couldn’t process the captured biometric data |
unknown |
An unclassified failure with no more specific reason available |
system_cancel |
The system itself canceled the request, e.g. another app came to the foreground |
user_fallback |
The user tapped the fallback/passcode button instead of using biometrics |
invalid_context |
The authentication context became invalid before the operation completed |
passcode_not_set |
The device has no passcode set, so biometric authentication can’t be enrolled |
authentication_failed |
The biometric or passcode check itself did not match |
Common questions
Section titled “Common questions”- Face ID falls back to the passcode, or logs “FaceID is available but has not been configured”.
NSFaceIDUsageDescriptionis missing from Info.plist; add it. - How do I check before prompting? Call
hasHardwareAsyncandisEnrolledAsyncfirst; authenticate only when both are true. - How do I customize the prompt?
promptMessage,cancelLabel,fallbackLabel, anddisableDeviceFallbackto block the device passcode after failed attempts. - Face ID does not work on a device. See the upstream report; check the usage description and that Face ID is enabled for the app in iOS settings.
Sources: Expo docs: LocalAuthentication, expo/expo#25055, Face ID and Touch ID with Expo.
How the wrapper works
Section titled “How the wrapper works”@symbiote-native/local-auth ships zero React/Vue/Angular/Svelte/Solid logic in expo-local-authentication
itself — that package’s own types file hard-imports Platform from the expo meta-package
(which this project never installs), so its functions, enums, and result types are hand-ported,
verbatim, into this package’s own core/, changing only that one import line to pull Platform
from expo-modules-core instead:
packages/local-auth/src/├── core/ # framework-agnostic: the six exported functions, AuthenticationType,│ # SecurityLevel, and the option/result/error types; native-module.ts resolves│ # the native module via expo-modules-core's requireNativeModule├── react/ # @symbiote-native/local-auth/react — export * from '../core'├── vue/ # @symbiote-native/local-auth/vue — export * from '../core'├── svelte/ # @symbiote-native/local-auth/svelte — export * from '../core'├── solid/ # @symbiote-native/local-auth/solid — export * from '../core'└── angular/ # @symbiote-native/local-auth/angular — export * from '../core'Unlike sensors’ react/hooks, vue/composables, svelte/runes, solid/primitives, and
angular/services folders — each full of per-sensor lifecycle wrappers — local-auth’s five
adapter entries are single-file re-exports with no lifecycle code at all: every function here is
stateless and one-shot, so there is nothing for a hook, composable, rune, primitive, or service to
subscribe to or clean up. The native code
itself is never
vendored or copied — expo-modules-autolinking resolves it straight out of node_modules (see
the native setup guide).