react-native-biometric-signature
v2.0.0
Published
React Native biometric key creation, signing, authentication prompts, and decryption for iOS and Android.
Maintainers
Readme
react-native-biometric-signature
Biometric-gated cryptographic signing, key creation, decryption, and authentication prompts for React Native on iOS and Android.
Private keys are generated inside the iOS Secure Enclave / Keychain or the Android Keystore (with optional StrongBox), and never leave the device. Cryptographic operations only run after the user authenticates with Face ID, Touch ID, fingerprint, iris, or — when explicitly allowed — the device passcode.
The public API mirrors Flutter's
biometric_signaturepackage, with idiomatic TypeScript types, React hooks, and full TurboModule + legacy-bridge support. Expo Go is not supported; use a development build or bare React Native.
Contents
- Highlights
- Installation
- Quick start
- Platform setup
- Security model
- End-to-end signing flow
- Hooks
- API reference
- Types
- Error handling
- Decryption
- Key and signature formats
- New and legacy architecture
- Troubleshooting
- Migration from Flutter
biometric_signature - Example app
- Contributing
- License
Highlights
- 🔐 Hardware-backed keys. ECDSA P-256 in the iOS Secure Enclave or the Android Keystore (StrongBox-eligible on API 28+).
- ✍️ Biometric-gated signing.
createSignatureissues a single Face ID / Touch ID / fingerprint prompt and signs the exact UTF-8 bytes of your payload — perfect for challenge-response auth. - 🔓 Optional asymmetric decryption. Pair the signing key with a decrypting key (RSA-OAEP-SHA256 on Android, hybrid Secure Enclave wrapping on iOS) for encrypted server-to-app payloads.
- ⚡ New + legacy arch. Ships a Codegen TurboModule spec and a
NativeModulesfallback — works in both architectures with no app-side branching. - 🧩 Expo config plugin.
app.jsondeclares the package once; the plugin injectsNSFaceIDUsageDescriptionand AndroidUSE_BIOMETRIC/USE_FINGERPRINTautomatically onprebuild/ EAS. - 🪝 First-class React hooks.
useBiometricAvailabilityanduseBiometricSignatureexpose loading, error, and last-result state for UI components. - 🛡️ Normalized errors. Cancellation, lockout, missing enrollment, key invalidation, and missing passcode are mapped to a stable
BiometricErrorunion on both platforms — no platform-specific error code matching. - 📦 Typed end-to-end. Full
.d.tsdeclarations exported from the package; types map to the runtime API one-to-one.
Installation
npm install react-native-biometric-signature
# or
yarn add react-native-biometric-signature
# or
pnpm add react-native-biometric-signaturePeer requirements:
| Peer | Range | Notes |
| -------------------------- | ---------------- | --------------------------------------------------------------------- |
| react-native | >=0.74 <1.0 | Recommended: 0.83.6 (matches example app + CI). |
| react | >=18.2.0 | |
| @expo/config-plugins | >=9 (optional) | Only required if you use the Expo plugin via app.json / app.config.js. |
Quick start
import {
biometricAuthAvailable,
createKeys,
createSignature,
} from 'react-native-biometric-signature';
async function signLogin(challenge: string): Promise<string | null> {
const availability = await biometricAuthAvailable();
if (!availability.canAuthenticate) {
return null; // Surface availability.reason to the user.
}
await createKeys('login-key', {
signatureType: 'ecdsa',
setInvalidatedByBiometricEnrollment: true,
});
const result = await createSignature(challenge, 'login-key');
return result.signature ?? null;
}That's the whole imperative path. createKeys is idempotent — calling it again with the same alias returns the existing key (unless you pass failIfExists: true).
For a complete server-issued-challenge flow including verification, see End-to-end signing flow.
Platform setup
Bare React Native
Autolinking handles registration on both platforms. After install:
cd ios && pod installAdd the Face ID usage string to ios/<YourApp>/Info.plist:
<key>NSFaceIDUsageDescription</key>
<string>Use Face ID to authorize sensitive actions.</string>On Android, declare the biometric permissions in android/app/src/main/AndroidManifest.xml if you're not using the Expo plugin:
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.USE_FINGERPRINT" />Expo (development builds + EAS)
Expo Go does not ship the native module — use a development build (
expo-dev-client) or EAS Build.
Reference the plugin from app.json:
{
"expo": {
"plugins": [
[
"react-native-biometric-signature",
{
"faceIDUsageDescription": "Use Face ID to authorize sensitive actions."
}
]
]
}
}Then:
npx expo prebuild --clean
npx expo run:ios
npx expo run:android
# or
eas build --platform ios
eas build --platform androidWhat the plugin does:
- iOS — writes
NSFaceIDUsageDescriptioninto the generatedInfo.plistand patches the Podfile to exposeReact-jsiheader paths forhermes-engineand the package's pod (works around a Hermes JSI header-search edge case on some RN minor versions). - Android — injects
android.permission.USE_BIOMETRICandandroid.permission.USE_FINGERPRINTinto the mergedAndroidManifest.xml.
iOS notes
- ECDSA P-256 keys are generated inside the Secure Enclave and tied to the user's biometric set.
- For
signatureType: 'rsa', the private key is generated in software, wrapped by a Secure Enclave EC key, stored as ciphertext in the Keychain, and unwrapped only after biometric authentication (transient unwrap, never persisted in plaintext). Use this when your server's verification stack expects RSA. setInvalidatedByBiometricEnrollment: trueinvalidates the key on biometric enrollment changes — adds defense-in-depth against Face ID enrollment hijacking, at the cost of forcing key re-enrollment when the user adds a fingerprint.NSFaceIDUsageDescriptionis required by Apple for any Face ID usage. Without it the prompt is silently denied.
Android notes
- ECDSA P-256 / RSA-2048 keys are generated in the Android Keystore via
KeyPairGenerator, bound tosetUserAuthenticationRequired(true)and the device's strong biometric class. useStrongBox: truerequests StrongBox-backed key material on API 28+; if the device's StrongBox can't satisfy the request, you getcode: 'notAvailable'(no silent fallback).- The biometric prompt itself runs on the main thread via AndroidX
BiometricPrompt. Key generation, file I/O, and post-auth crypto are dispatched toDispatchers.IOvia Kotlin coroutines so they don't block UI. simplePromptacceptsbiometricStrength: 'weak'for prompt-only auth (e.g., unlocking a screen) where weak biometrics are acceptable; key-bound signing always requires strong.- For
enableDecryption: truewith ECDSA, the signing key stays in the Keystore and a software ECIES decrypting key is wrapped with an Android Keystore (or StrongBox) AES key.
Security model
| Concern | Behavior |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Where private keys live | iOS Secure Enclave / Keychain. Android Keystore (optionally StrongBox). |
| Can the app read the key? | No. Private key material is never exposed to JS or to the native bridge; only the public key is returned. |
| When does the prompt fire? | On every key-bound signing / decryption call. simplePrompt triggers a standalone prompt with no key operation. |
| Key invalidation | If setInvalidatedByBiometricEnrollment: true (default off for now — opt-in), adding or removing biometrics invalidates the key. |
| Backup / sync | Keys are non-exportable and non-syncable. Replacing a device means re-enrolling. This is by design — it makes possession of the key device-bound. |
| Default key alias | biometric_key (matches the Flutter biometric_signature plugin for cross-plugin key sharing under the same applicationID / Team ID + Bundle ID). Override with a per-feature alias (e.g. auth-key, payment-key) for clean revocation. |
| Algorithms | ECDSA P-256 with SHA-256 (default), or RSA-2048 PKCS#1 v1.5 + RSA-OAEP-SHA256 for decryption (no SHA-1 allowed). |
You're responsible for the verification side. This package gives you cryptographically strong proof of "the user authenticated on the device that holds this key". Pair it with a challenge-response handshake on your server — see the next section.
End-to-end signing flow
The library produces a signature; your server verifies it against the registered public key. Don't sign static strings — always use server-issued single-use challenges.
import {
biometricAuthAvailable,
createKeys,
createSignature,
getKeyInfo,
} from 'react-native-biometric-signature';
const ALIAS = 'login-key';
// 1) Enrollment — called once after the user opts in to biometric login.
async function enrollDevice(userId: string): Promise<void> {
const availability = await biometricAuthAvailable();
if (!availability.canAuthenticate) {
throw new Error(availability.reason ?? 'Biometrics unavailable');
}
const created = await createKeys(ALIAS, {
signatureType: 'ecdsa',
setInvalidatedByBiometricEnrollment: true,
failIfExists: false,
});
if (created.error) {
throw new Error(created.error);
}
// Send the PEM public key + a stable device id to your server, keyed by user.
await fetch('/api/biometric/enroll', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId,
publicKey: created.publicKey,
algorithm: created.algorithm,
}),
});
}
// 2) Sign-in — called each time the user authenticates.
async function biometricSignIn(userId: string): Promise<string> {
// The server issues a short-lived single-use challenge.
const { challenge } = await (
await fetch(`/api/biometric/challenge?userId=${userId}`)
).json();
// Confirm the key still exists and is valid; recreate if invalidated.
const info = await getKeyInfo(ALIAS, true);
if (!info.exists || !info.isValid) {
await enrollDevice(userId); // re-enroll if biometrics changed
}
const result = await createSignature(challenge, ALIAS);
if (result.error || !result.signature) {
throw new Error(result.error ?? 'Signing failed');
}
// Server verifies the signature against the stored public key + challenge.
const { sessionToken } = await (
await fetch('/api/biometric/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId,
challenge,
signature: result.signature,
}),
})
).json();
return sessionToken;
}Server-side verification (Node.js example) is a single crypto.verify call:
import { createVerify, createPublicKey } from 'node:crypto';
function verifyEcdsaSignature(
publicKeyPem: string,
challenge: string,
signatureBase64: string
): boolean {
const verifier = createVerify('SHA256');
verifier.update(challenge);
verifier.end();
return verifier.verify(
createPublicKey(publicKeyPem),
Buffer.from(signatureBase64, 'base64')
);
}Challenges should expire fast (≤2 min) and be single-use — store them in Redis or a short-lived JWT.
Hooks
Hooks never trigger biometric prompts on mount; you call the prompting methods explicitly. State (isLoading, error, lastSignature, etc.) reflects the last operation.
import { Button, Text, View } from 'react-native';
import { useBiometricSignature } from 'react-native-biometric-signature';
export function LoginScreen({ challenge }: { challenge: string }) {
const bio = useBiometricSignature({ keyAlias: 'login-key' });
return (
<View>
{bio.error ? <Text>Error: {String(bio.error)}</Text> : null}
{bio.lastSignature?.signature ? (
<Text>Signed: {bio.lastSignature.signature.slice(0, 16)}…</Text>
) : null}
<Button
title={bio.isLoading ? 'Authenticating…' : 'Sign in with biometrics'}
disabled={bio.isLoading}
onPress={() => {
void bio.createSignature(challenge);
}}
/>
</View>
);
}For availability checks (without prompting), use useBiometricAvailability:
import { useBiometricAvailability } from 'react-native-biometric-signature';
const { availability, isLoading, refreshAvailability } = useBiometricAvailability({
refreshOnMount: true,
});API reference
Availability
biometricAuthAvailable(): Promise<BiometricAvailability>
Reports whether biometric authentication is available, enrolled, and which modalities the device supports. Never prompts.
type BiometricAvailability = {
canAuthenticate?: boolean;
hasEnrolledBiometrics?: boolean;
availableBiometrics?: BiometricType[]; // 'face' | 'fingerprint' | 'iris' | 'multiple' | 'unavailable'
reason?: string;
};isDeviceLockSet(): Promise<boolean>
True if the device has any secure lock (passcode, PIN, pattern). Android uses KeyguardManager.isDeviceSecure(); iOS uses LAContext.canEvaluatePolicy(.deviceOwnerAuthentication). Useful as a precondition before prompting for sensitive flows.
isSensorAvailable()
Alias for biometricAuthAvailable(). Convenience export for codebases coming from react-native-biometrics.
Key lifecycle
createKeys(keyAlias?, config?, keyFormat?, promptMessage?): Promise<KeyCreationResult>
Creates (or returns) a biometric-bound signing keypair. Optionally creates a paired decrypting key.
Defaults: keyAlias = "biometric_key", keyFormat = "pem", signatureType = "ecdsa".
Config (CreateKeysConfig):
| Option | Default | Effect |
| -------------------------------------- | --------- | ---------------------------------------------------------------------------------------------- |
| signatureType | 'ecdsa' | 'ecdsa' for P-256 / SHA-256, 'rsa' for RSA-2048 PKCS#1 v1.5. |
| enableDecryption | false | Also create a paired decrypting key (ECIES on iOS/Android-EC, RSA-OAEP-SHA256 elsewhere). |
| setInvalidatedByBiometricEnrollment | false | Invalidate key on biometric enrollment changes. Strong defense, friction tradeoff. |
| useStrongBox (Android) | false | Require StrongBox HSM backing on API 28+. Fails with 'notAvailable' if unsupported. |
| useDeviceCredentials (Android) | false | Allow PIN/pattern/password fallback for biometric-bound operations. |
| enforceBiometric | false | Require biometric auth specifically (Android: strong class only). |
| failIfExists | false | Reject with keyAlreadyExists instead of returning the existing key. |
| promptSubtitle / promptDescription | | Override Android BiometricPrompt text. iOS uses the iOS-native LocalizedReason. |
| cancelButtonText | | Android BiometricPrompt negative button text. |
getKeyInfo(keyAlias?, checkValidity?, keyFormat?): Promise<KeyInfo>
Returns key existence, validity, algorithm, key size, public key (in the requested format), and hybrid-mode metadata. checkValidity: true performs a real Keystore/Keychain probe rather than a presence check.
deleteKeys(keyAlias?): Promise<boolean>
Deletes the signing key and any paired decrypting key for one alias. Returns false if the native call errors (e.g., Keychain locked).
deleteAllKeys(): Promise<boolean>
Deletes every key this package created and tracks.
Signing
createSignature(payload, keyAlias?, config?, signatureFormat?, keyFormat?, promptMessage?): Promise<SignatureResult>
Prompts the user and signs the exact UTF-8 bytes of payload.
Defaults: signatureFormat = "base64", keyFormat = "pem".
type SignatureResult = {
signature?: string; // base64 | hex string, depending on signatureFormat
signatureBytes?: number[]; // populated when signatureFormat === 'raw'
publicKey?: string;
error?: string;
code?: BiometricError;
algorithm?: string;
keySize?: number;
authenticationType?: AuthenticationType; // 'biometric' | 'credential' | 'unknown'
};createBiometricSignature(...)
Alias for createSignature(...). Compatible with codebases coming from react-native-biometrics.
Decryption
decrypt(payload, keyAlias?, payloadFormat?, config?, promptMessage?): Promise<DecryptResult>
Prompts the user and decrypts ciphertext using the key created by createKeys(..., { enableDecryption: true }). Default payloadFormat = "base64". See Decryption for the full flow.
Authentication prompts
simplePrompt(promptMessage, config?): Promise<SimplePromptResult>
Standalone biometric prompt with no cryptographic operation. Use this for unlock-style flows where you don't need a signature. config.biometricStrength: 'weak' is accepted on Android.
Convenience
biometricKeyExists({ keyAlias?, checkValidity?, keyFormat? }): Promise<BiometricKeyExistsResult>
Thin wrapper over getKeyInfo that returns { exists, isValid? }.
Hooks
useBiometricAvailability(options?: { refreshOnMount?: boolean })
Returns { availability, isLoading, error, refreshAvailability }.
useBiometricSignature(options?: { keyAlias?, refreshAvailabilityOnMount? })
Returns the full imperative API bound to a keyAlias, plus stateful lastSignature, lastDecryptResult, lastKeyInfo, lastPromptResult, isLoading, error, and availability.
Error utilities
import {
getErrorMessage,
isBiometricError,
normalizeBiometricError,
} from 'react-native-biometric-signature';getErrorMessage(err)— safemessageextraction fromunknown.isBiometricError(value)— type guard for theBiometricErrorunion.normalizeBiometricError(value)— maps native LAError / Keystore exception strings into the normalizedBiometricErrorunion. Useful when surfacing errors from third-party native modules through the same code path.
Types
All types are exported from the package entrypoint:
import type {
AuthenticationType,
BiometricAvailability,
BiometricError,
BiometricKeyExistsOptions,
BiometricKeyExistsResult,
BiometricStrength,
BiometricType,
CreateKeysConfig,
CreateSignatureConfig,
DecryptConfig,
DecryptResult,
KeyCreationResult,
KeyFormat,
KeyInfo,
PayloadFormat,
SignatureFormat,
SignatureResult,
SignatureType,
SimplePromptConfig,
SimplePromptResult,
UseBiometricAvailabilityOptions,
UseBiometricAvailabilityResult,
UseBiometricSignatureOptions,
UseBiometricSignatureResult,
} from 'react-native-biometric-signature';The BiometricError union:
type BiometricError =
| 'success'
| 'userCanceled'
| 'notAvailable'
| 'notEnrolled'
| 'lockedOut'
| 'lockedOutPermanent'
| 'keyNotFound'
| 'keyInvalidated'
| 'unknown'
| 'invalidInput'
| 'securityUpdateRequired' // Android-only
| 'notSupported'
| 'systemCanceled'
| 'promptError'
| 'keyAlreadyExists'
| 'passcodeNotSet';
lockedOutPermanentfires natively on Android (BiometricPrompt.ERROR_LOCKOUT_PERMANENT) and is synthesized on iOS when anLAError.biometryLockoutrecurs on a follow-upcanEvaluatePolicycheck. Treat both as "biometrics require device passcode unlock to recover."
securityUpdateRequiredis Android-only.
Error handling
Expected biometric outcomes (cancellation, lockout, no enrollment, key invalidation, missing passcode) are returned as result objects with code and error fields, not thrown:
const result = await createSignature(challenge, 'login-key');
if (result.code === 'userCanceled') {
// User dismissed the prompt — typically a no-op in the UI.
return;
}
if (result.code === 'keyInvalidated' || result.code === 'keyNotFound') {
// Re-enroll the device.
await createKeys('login-key', { signatureType: 'ecdsa' });
return retry();
}
if (result.code === 'lockedOutPermanent') {
showHint('Unlock your device with the passcode to re-enable biometrics.');
return;
}
if (result.error || !result.signature) {
reportError(result.error ?? 'Unknown biometric error');
return;
}
submit(result.signature);Programmer errors do throw: invalid argument types, empty payloads, unsupported format strings, and missing native module initialization all surface as TypeError / Error so they're caught loudly during development.
Decryption
Decryption needs a key created with enableDecryption: true:
await createKeys('payments-key', {
signatureType: 'ecdsa',
enableDecryption: true,
});
const info = await getKeyInfo('payments-key');
// info.decryptingPublicKey is the PEM you send to the backend.
// Backend encrypts payload using info.decryptingPublicKey,
// then the app decrypts after a biometric prompt:
const { decryptedData } = await decrypt(
ciphertextBase64,
'payments-key',
'base64' // payload format
);Platform mapping:
| Platform | Signing | Decryption | | -------- | ------- | ----------- | | iOS — ECDSA | Secure Enclave P-256 | Secure Enclave ECIES (same key) | | iOS — RSA | Hybrid: software RSA wrapped by Secure Enclave EC | Same hybrid structure; RSA-OAEP-SHA256 after unwrap | | Android — ECDSA | Keystore P-256 | Hybrid: software ECIES wrapped by Keystore (or StrongBox) AES | | Android — RSA | Keystore RSA-2048 PKCS#1 v1.5 | Keystore RSA-2048 OAEP-SHA256 (paired key) |
SHA-1 is not permitted on either platform — the OAEP key is created with
DIGEST_SHA256only.
Key and signature formats
Public keys (KeyFormat):
pem(default) — PEM-encodedSubjectPublicKeyInfobase64— Base64 DERhex— Hex DERraw— bytes returned inpublicKeyBytes
Signatures (SignatureFormat):
base64(default) — base64 string insignaturehex— hex string insignatureraw— bytes returned insignatureBytes
There is no private-key export API by design.
New and legacy architecture
The package ships:
- A Codegen TurboModule spec at
src/specs/NativeBiometricSignature.ts— picked up byreact-nativecodegen whennewArchEnabled = true. - A legacy-bridge fallback in
src/NativeBiometricSignature.tsthat resolves the same native module viaNativeModules.BiometricSignature(orNativeModules.RNBiometricSignature) whennewArchEnabled = false.
Apps don't need to branch — the JS layer auto-selects whichever path is registered.
Troubleshooting
| Symptom | Fix |
| ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- |
| The package does not seem to be linked | Run pod install; rebuild the native app; confirm you're using a development build (not Expo Go). |
| Face ID prompt does not appear | Add NSFaceIDUsageDescription to Info.plist (or via the Expo plugin). |
| Android prompt returns notEnrolled | Enroll a biometric, or pass useDeviceCredentials: true if your flow allows the device-credential fallback. |
| Returns keyInvalidated after enrolling a new face / fingerprint | Re-create the key. This is intentional if setInvalidatedByBiometricEnrollment: true. |
| decrypt returns keyNotFound | Create the key with enableDecryption: true before calling decrypt. |
| Android emulator can't reach Metro | Run with REACT_NATIVE_PACKAGER_HOSTNAME=127.0.0.1. The example app's npm run example:android script sets this for you. |
| lockedOutPermanent on iOS | Tell the user to unlock with the device passcode — Apple disables biometry until then. |
Migration from Flutter biometric_signature
The mirrored method names are intentionally stable:
biometricAuthAvailable · createKeys · createSignature · decrypt · deleteKeys · deleteAllKeys · getKeyInfo · simplePrompt · isDeviceLockSet
React Native-specific additions (isSensorAvailable, biometricKeyExists, createBiometricSignature, hooks) are additive wrappers — they don't replace the mirrored API. If you're porting a Flutter app's biometric layer, the call sites translate one-to-one; only the surrounding state plumbing changes.
Example app
A complete Expo example lives in example/. It exercises every API: availability checks, key creation with both ECDSA and RSA modes, signing, decryption, prompt-only auth, and key info / deletion.
nvm use 24.15.0
npm install
# iOS
npm run example:ios
# Android
npm run example:android
# Metro only
npm run exampleThe example targets Expo SDK 55.0.24 and React Native 0.83.6.
Contributing
PRs and issues welcome. Before opening a PR:
npm run typecheck
npm run lint
npm test
npm run buildSee CONTRIBUTING.md and CODE_OF_CONDUCT.md.
