@intellij-shivam/react-native-biometric
v0.1.0
Published
Biometric authentication for React Native — Face ID, Touch ID, Optic ID, and Android fingerprint/face unlock via BiometricPrompt. Hardware-backed signing (Secure Enclave, StrongBox, Keystore), New Architecture TurboModule, consistent cross-platform crypto
Maintainers
Readme
react-native-biometric
Biometric authentication for React Native — Face ID, Touch ID, Optic ID, and Android fingerprint / face unlock, with hardware-backed cryptographic signing. The maintained, New Architecture successor to the unmaintained react-native-biometrics.
A TurboModule built in Swift and Kotlin on the modern platform APIs (LocalAuthentication + Secure Enclave on iOS, BiometricPrompt + Keystore/StrongBox on Android), with a small, fully typed JavaScript API.
- New Architecture native. A codegen'd TurboModule — synchronous type-safe bindings, Hermes-ready, no legacy bridge.
- Identical crypto on both platforms. Public keys are always base64 X.509 SubjectPublicKeyInfo (SPKI) DER; signatures are standard ECDSA-SHA256 (DER) or RSA-PKCS#1-v1.5-SHA256. Verify with OpenSSL, WebCrypto, JCA, or any server-side library — no platform-specific fixups.
- No authentication bypasses by construction. Signing keys are auth-per-use: the Keystore/Keychain itself refuses to sign without a fresh biometric authorization. There is no code path that returns a signature without a prompt, and no "recently unlocked" grace period.
- Consistent behavior where possible, documented behavior where not. Key creation is silent on both platforms. Keys invalidate on enrollment change on both platforms. Disabling the passcode fallback works on both platforms. Where the platforms genuinely differ, the difference is documented instead of papered over.
- One error vocabulary. Both platforms produce the same typed error codes for the same logical condition —
userCancelmeans the same thing everywhere. - Cancellation is not an exception. Prompts resolve with a discriminated union (
{ success: false, code: 'userCancel' }), so your control flow doesn't live incatchblocks.
📘 New here? The Implementation Guide walks through the two integration flows step by step — UI gating and server-verified biometric login — with complete client and server code, an error-handling cookbook, and simulator/emulator testing instructions.
Requirements
| | Minimum | |---|---| | React Native | 0.76+ (New Architecture) | | iOS | 15.1+ | | Android | API 24+ (Android 7.0) |
Installation
npm install @intellij-shivam/react-native-biometric
# or
yarn add @intellij-shivam/react-native-biometriccd ios && pod installiOS
Add a Face ID usage description to your Info.plist (required by iOS whenever Face ID may be used):
<key>NSFaceIDUsageDescription</key>
<string>Authenticate with Face ID to sign in</string>Android
Nothing to do. The USE_BIOMETRIC permission is merged in automatically from the library manifest.
Quick start
import {
getBiometrics,
authenticate,
createSigningKey,
sign,
} from '@intellij-shivam/react-native-biometric';
// 1. What can this device do?
const info = await getBiometrics();
// { available: true, enrolled: true, biometryType: 'faceId',
// securityLevel: 'strong', deviceCredentialEnrolled: true }
// 2. Gate some UI behind a prompt (no cryptography).
const auth = await authenticate({ title: 'Unlock your notes' });
if (auth.success) {
// auth.authMethod: 'biometric' | 'deviceCredential'
} else if (auth.code !== 'userCancel') {
showError(auth.code); // typed: 'lockout' | 'notEnrolled' | ...
}
// 3. Or prove it to your server: enroll once…
const { publicKey } = await createSigningKey();
await api.registerBiometricKey(publicKey); // base64 SPKI DER
// …then sign a server challenge on each login.
const challenge = await api.getChallenge();
const result = await sign({ payload: challenge, title: 'Sign in' });
if (result.success) {
await api.login(challenge, result.signature);
} else if (result.code === 'keyInvalidated') {
// Biometric enrollment changed → rotate the key.
await createSigningKey().then((k) => api.registerBiometricKey(k.publicKey));
}Why not authenticate() for login?
authenticate() only proves something to your JavaScript, which a compromised device can lie to. For anything a server trusts, use createSigningKey() + sign(): the private key never leaves secure hardware (Secure Enclave / StrongBox / TEE), and the OS only produces a signature after a genuine biometric authorization. Your server verifies the signature against the public key it stored at enrollment.
API
getBiometrics(): Promise<BiometricsInfo>
Describes current capabilities. Never prompts, never throws for expected states.
interface BiometricsInfo {
available: boolean; // can a prompt be shown right now?
enrolled: boolean; // is any biometric enrolled?
biometryType: 'faceId' | 'touchId' | 'opticId' | 'biometrics' | 'none';
securityLevel: 'strong' | 'weak' | 'none'; // Android Class 3 / Class 2
deviceCredentialEnrolled: boolean; // PIN/pattern/passcode set?
reason?: 'noHardware' | 'notEnrolled' | 'lockedOut' | 'unavailable' | 'securityUpdateRequired';
}Why
biometryTypeis just'biometrics'on Android: Android does not reveal which sensor (fingerprint, face, iris) a prompt will use. Libraries that report "fingerprint" or "face" on Android are guessing fromPackageManagerhardware flags — a guess that's wrong on multi-sensor devices. We report what the platform actually knows, andsecurityLeveltells you what matters: whether a Class 3 (strong) sensor is available.
authenticate(options): Promise<AuthenticateResult>
Shows a biometric prompt with no cryptography attached.
const result = await authenticate({
title: 'Unlock', // required
subtitle: 'Use your fingerprint', // Android
description: 'Shown as the reason on iOS',
cancelLabel: 'Not now',
allowDeviceCredential: false, // default false — reliably biometric-only on BOTH platforms
securityLevel: 'strong', // Android: 'weak' also accepts Class 2 face unlock
confirmationRequired: false, // Android: explicit tap after passive face/iris auth
});
// { success: true, authMethod: 'biometric' | 'deviceCredential' }
// | { success: false, code: BiometricErrorCode, message: string }- To support devices with Class 2 (weak) face unlock, pass
securityLevel: 'weak'. Weak biometrics can gate UI but cannot authorize hardware-backed signing (an Android platform rule, not a library limitation). allowDeviceCredential: trueadds PIN/pattern/passcode fallback. On iOS this uses a combined policy; when the user taps "Use Passcode" inside that prompt, iOS does not report which method completed, soauthMethodreflects the method iOS offered first. Android always reports the exact method.
createSigningKey(options?): Promise<CreateKeyResult>
Creates a hardware-backed key pair. Silent on both platforms — the prompt appears when you sign(), never at creation. Replaces any existing key under the same alias.
const key = await createSigningKey({
alias: 'login-key', // optional, defaults to a library alias
algorithm: 'ec256', // default; or 'rsa2048'
accessControl: 'biometryCurrentSet', // default; or 'biometryAny' | 'biometryOrDeviceCredential'
strongBox: 'preferred', // Android: 'preferred' | 'required' | 'never'
});
// { publicKey: string /* base64 SPKI DER */,
// algorithm: 'ec256',
// securityLevel: 'strongBox' | 'secureEnclave' | 'trustedEnvironment' | 'software',
// insideSecureHardware: boolean }Throws BiometricError with notEnrolled, passcodeNotSet, or unsupported (e.g. strongBox: 'required' on a device without StrongBox).
Access control:
| accessControl | Usable after | Enrollment change |
|---|---|---|
| biometryCurrentSet (default) | biometric auth only | key permanently invalidated — both platforms |
| biometryAny | biometric auth only | key survives (weaker; choose deliberately) |
| biometryOrDeviceCredential | biometric or PIN/pattern/passcode | key survives (platform limitation) |
Algorithm guidance: prefer ec256 (ECDSA P-256). It's the only algorithm the iOS Secure Enclave supports, it's faster, and its keys are smaller. Use rsa2048 only when your backend demands RSA — on iOS, RSA private keys live in the keychain (still auth-gated and reported honestly as securityLevel: 'software'), not the Secure Enclave.
sign(options): Promise<SignResult>
Signs a payload, gated by a biometric prompt.
const result = await sign({
payload: challenge, // required
payloadEncoding: 'utf8', // or 'base64' for binary payloads
title: 'Sign in', // required
alias: 'login-key',
cancelLabel: 'Cancel',
allowDeviceCredential: false, // only honored for biometryOrDeviceCredential keys
});
// { success: true, signature: string /* base64 */, authMethod: 'biometric' | 'deviceCredential' }
// | { success: false, code: BiometricErrorCode, message: string }Signature formats (identical on both platforms):
| Key | Signature | Verify with |
|---|---|---|
| ec256 | ECDSA-SHA256, ASN.1 DER (RFC 3279) | SHA256withECDSA, WebCrypto ECDSA (after DER→raw), OpenSSL |
| rsa2048 | RSASSA-PKCS1-v1_5 with SHA-256 | SHA256withRSA, WebCrypto RSASSA-PKCS1-v1_5, OpenSSL |
Server-side verification in Node.js:
const { createVerify, createPublicKey } = require('node:crypto');
const key = createPublicKey({
key: Buffer.from(publicKeyBase64, 'base64'), // straight from createSigningKey()
format: 'der',
type: 'spki',
});
const ok = createVerify('sha256')
.update(challenge)
.verify(key, Buffer.from(signatureBase64, 'base64'));getPublicKey(alias?): Promise<string | null> · hasSigningKey(alias?): Promise<boolean> · deleteSigningKey(alias?): Promise<boolean>
Key housekeeping. None of these ever show a prompt. deleteSigningKey resolves true if a key was deleted, false if none existed.
Error codes
Every failure — resolved ({ success: false, code }) or thrown (BiometricError.code) — uses one vocabulary:
| Code | Meaning |
|---|---|
| userCancel | User dismissed the prompt (cancel button, back gesture, tap outside) |
| systemCancel | OS dismissed the prompt (app backgrounded, incoming call, …) |
| authenticationFailed | Biometric didn't match after the allowed retries (iOS; Android keeps the prompt open and eventually reports lockout) |
| lockout | Too many failed attempts; biometry temporarily locked |
| lockoutPermanent | Biometry locked until the user re-authenticates with their device credential |
| notEnrolled | No biometric enrolled |
| noHardware | No biometric hardware |
| passcodeNotSet | No device PIN/pattern/passcode (required for credential fallback and for key creation) |
| unavailable | Biometric hardware temporarily unavailable |
| keyNotFound | No signing key under that alias |
| keyInvalidated | Biometric enrollment changed; delete + recreate the key and re-register the public key |
| unsupported | Requested option isn't supported on this device |
| unknown | Anything else; message carries the platform detail |
Platform behavior matrix
Where iOS and Android genuinely differ, we document it rather than pretend:
| Behavior | iOS | Android |
|---|---|---|
| Key creation prompt | silent | silent |
| Key invalidated on enrollment change (biometryCurrentSet) | ✅ | ✅ |
| Prompt required for every signature | ✅ (Keychain ACL) | ✅ (auth-per-use Keystore key) |
| Biometric-only enforcement (allowDeviceCredential: false) | ✅ (fallback button hidden) | ✅ (BIOMETRIC_STRONG only) |
| Concrete sensor reported | Face ID / Touch ID / Optic ID | generic biometrics (platform doesn't say) |
| Weak (Class 2) biometrics for authenticate | n/a (always strong) | opt-in via securityLevel: 'weak' |
| Weak biometrics for sign | n/a | ❌ — Android requires Class 3 for Keystore auth; we fail honestly instead of skipping the prompt |
| authMethod accuracy with credential fallback | best-effort (see authenticate) | exact |
| RSA keys in secure hardware | ❌ Secure Enclave is EC-only (reported as software) | ✅ TEE/StrongBox |
How it compares
| | react-native-biometric (this) | react-native-biometrics (SelfLender) | expo-local-authentication | react-native-keychain |
|---|---|---|---|---|
| Maintained | ✅ | ❌ unmaintained | ✅ | ✅ |
| New Architecture (TurboModule) | ✅ native | ❌ | ✅ | ✅ |
| Hardware-backed signing (Secure Enclave / StrongBox) | ✅ | ✅ (RSA only) | ❌ prompt only | ❌ secret storage, no signing |
| Same public-key format on both platforms | ✅ SPKI | ❌ | n/a | n/a |
| Prompt required for every signature (no grace period) | ✅ | ❌ bypassable | n/a | n/a |
| Key invalidation on enrollment change (both platforms) | ✅ | Android only | n/a | partial |
| Typed cross-platform error codes | ✅ | ❌ | partial | ❌ |
Use expo-local-authentication if you only need a prompt inside Expo Go. Use react-native-keychain for storing secrets. Use this library when a server needs to trust the authentication — biometric login, transaction signing, passwordless auth.
Migrating
// Before
const rnBiometrics = new ReactNativeBiometrics();
const { available, biometryType } = await rnBiometrics.isSensorAvailable();
const { success } = await rnBiometrics.simplePrompt({ promptMessage: 'Confirm' });
const { publicKey } = await rnBiometrics.createKeys();
const { signature } = await rnBiometrics.createSignature({ promptMessage: 'Sign in', payload });
// After — no class instance, direct imports
const { available, biometryType } = await getBiometrics();
const { success } = await authenticate({ title: 'Confirm' });
const { publicKey } = await createSigningKey(); // ec256 by default, was RSA
const result = await sign({ title: 'Sign in', payload });Notes: keys default to ec256 (use algorithm: 'rsa2048' for drop-in server compatibility with old RSA verification code); public keys are SPKI on both platforms (the old library returned raw PKCS#1 on iOS).
| Before | After |
|---|---|
| isSensorAvailable() | getBiometrics() |
| simplePrompt(...) / authenticateWithOptions(...) | authenticate({...}) |
| createKeys(alias, type) / createKeysWithType | createSigningKey({ alias, algorithm }) |
| signWithOptions / verifyKeySignature | sign({...}) — always prompts, never verifies locally* |
| deleteKeys(alias) | deleteSigningKey(alias) |
| doesKeyExist / getAllKeys | hasSigningKey(alias) / getPublicKey(alias) |
* Local "verify signature" APIs give a false sense of security — verification belongs on the server that holds the public key. If you need local verification for tests, use the SPKI public key with any crypto library.
Behavioral differences you'll appreciate: public keys are the same format on both platforms; there is no signature path that bypasses the prompt; error codes are identical across platforms; createKeys no longer prompts on Android.
Documentation
- Implementation Guide — step-by-step integration: UI gating, server-verified login (client + Node.js server code), error cookbook, simulator/emulator testing, troubleshooting
- API reference — every function, option, and result type (this README)
- Error codes · Platform behavior matrix · Migration guides
Example app
yarn
yarn example ios # or: yarn example androidThe example exercises every API and logs typed results, including error codes for cancel/lockout flows.
Roadmap
- Biometric enrollment-change events (JS listener, both architectures)
- Expo config plugin (
NSFaceIDUsageDescriptionautomation) - Play Integrity / App Attest helpers
- Key attestation surface (Android
attestationChallenge)
License
MIT
