Quick Answer: Create an RSA key pair on the phone with
react-native-biometrics, send only the public key to your server, and at login have the phone sign a one-time challenge after a biometric check. Your server verifies the signature and issues a session. No fingerprint, face scan, or template ever leaves the device.
Biometric login is easy to bolt on badly. The shortcut is to trust a "success" boolean from the app, which anyone can fake on a modified build.
This tutorial builds the proper version end to end: client, server, and tests. It is a design pattern, not legal advice, and we explain why it reduces what your app holds in our guide to what to check before hiring a mobile app development company in Chicago, where Illinois' BIPA makes biometric data a real liability.
What Are We Building?
A three-step flow: enroll a device, request a challenge, and verify a signature. Each step is a small function, and the server never touches a biometric.
Enroll: phone creates key pair -> sends PUBLIC key -> server stores it Login: server sends random challenge phone prompts biometrics -> signs challenge with PRIVATE key server verifies signature with stored public key -> issues session The private key stays in the device keystore and is released only after a successful biometric check. The server holds only a public key, which is useless to an attacker on its own.
What Do You Need Before You Start?
You need a React Native app (0.60 or later), a Node server, and the react-native-biometrics package. The library is a bridge to native iOS and Android keystore management and signs with RSA PKCS#1 v1.5 and SHA-256.
# app npm install react-native-biometrics npx pod-install # server npm install express On iOS, add an NSFaceIDUsageDescription entry to your Info.plist, or Face ID will not work properly. Check the package README for the Android SDK requirements of your version, and test on real devices because simulators behave inconsistently with keystores.
How Does the App Enroll a Device?
Enrollment generates a key pair and registers the public half with your server. Do it only while the user is already signed in through another method, such as a password or magic link, so you know whose device you are registering.
// biometric.ts import ReactNativeBiometrics from 'react-native-biometrics'; const rnBiometrics = new ReactNativeBiometrics(); const API = 'https://api.example.com'; async function post(path: string, body: unknown, token?: string) { return fetch(`${API}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), }, body: JSON.stringify(body), }); } export async function enrollDevice(userId: string, sessionToken: string) { const { available } = await rnBiometrics.isSensorAvailable(); if (!available) throw new Error('BIOMETRICS_UNAVAILABLE'); const { publicKey } = await rnBiometrics.createKeys(); const res = await post('/biometric/enroll', { userId, publicKey }, sessionToken); if (!res.ok) throw new Error('ENROLL_FAILED'); } createKeys() returns a base64-encoded RSA 2048 public key. Nothing biometric is involved at this point, which is the whole idea.
How Does the Server Issue and Verify a Challenge?
The server creates a random, short-lived, single-use challenge, then verifies the phone's signature over exactly that challenge. Deleting the challenge before verifying it blocks replay attacks.
// server.ts import express from 'express'; import { randomBytes, createVerify } from 'node:crypto'; const CHALLENGE_TTL_MS = 60_000; const challenges = new Map<string, { value: string; expiresAt: number }>(); const publicKeys = new Map<string, string>(); // userId -> PEM. Use your database. export function toPem(base64Key: string): string { const body = base64Key.match(/.{1,64}/g)?.join('\n') ?? ''; return `-----BEGIN PUBLIC KEY-----\n${body}\n-----END PUBLIC KEY-----\n`; } export function verifySignature(pem: string, payload: string, signatureB64: string): boolean { const verifier = createVerify('RSA-SHA256'); verifier.update(payload); verifier.end(); return verifier.verify(pem, Buffer.from(signatureB64, 'base64')); } const app = express(); app.use(express.json()); // Your existing auth middleware goes here. app.post('/biometric/enroll', requireAuth, (req, res) => { const { userId, publicKey } = req.body; publicKeys.set(userId, toPem(publicKey)); res.sendStatus(204); }); app.post('/biometric/challenge', (req, res) => { const { userId } = req.body; if (!publicKeys.has(userId)) return res.sendStatus(404); const value = randomBytes(32).toString('base64url'); challenges.set(userId, { value, expiresAt: Date.now() + CHALLENGE_TTL_MS }); res.json({ challenge: value }); }); app.post('/biometric/verify', (req, res) => { const { userId, challenge, signature } = req.body; const stored = challenges.get(userId); challenges.delete(userId); // single use, even if verification fails const pem = publicKeys.get(userId); if (!stored || !pem) return res.sendStatus(401); if (stored.value !== challenge || Date.now() > stored.expiresAt) return res.sendStatus(401); if (!verifySignature(pem, challenge, signature)) return res.sendStatus(401); res.json({ sessionToken: issueSession(userId) }); // your normal session logic }); Add rate limiting to /biometric/challenge and /biometric/verify, and store challenges in Redis or your database if you run more than one server instance.
How Does the App Log In?
The app asks for a challenge, signs it behind a biometric prompt, and returns the signature. The payload you sign must be the exact challenge string the server issued.
export async function biometricLogin(userId: string): Promise<string | null> { const { keysExist } = await rnBiometrics.biometricKeysExist(); if (!keysExist) return null; // fall back to password or passcode const challengeRes = await post('/biometric/challenge', { userId }); if (!challengeRes.ok) return null; const { challenge } = await challengeRes.json(); const { success, signature } = await rnBiometrics.createSignature({ promptMessage: 'Sign in', payload: challenge, }); if (!success || !signature) return null; const verifyRes = await post('/biometric/verify', { userId, challenge, signature }); if (!verifyRes.ok) return null; const { sessionToken } = await verifyRes.json(); return sessionToken; } The library also offers simplePrompt(), but its own documentation says it only validates the user's biometrics and should not be used to authenticate with a server. Use createSignature() for login.
How Do You Test the Verification Without a Phone?
Generate an RSA key pair in the test, sign a challenge the way the phone would, and check that your server logic accepts it and rejects tampering. This catches format mistakes before you ever touch a device.
// biometric.test.ts import test from 'node:test'; import assert from 'node:assert/strict'; import { generateKeyPairSync, createSign } from 'node:crypto'; import { toPem, verifySignature } from './server'; test('accepts a valid signature and rejects a tampered challenge', () => { const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); const spkiBase64 = publicKey.export({ type: 'spki', format: 'der' }).toString('base64'); const challenge = 'example-challenge'; const signature = createSign('RSA-SHA256').update(challenge).sign(privateKey, 'base64'); assert.equal(verifySignature(toPem(spkiBase64), challenge, signature), true); assert.equal(verifySignature(toPem(spkiBase64), 'tampered', signature), false); }); Run it with node --import tsx --test biometric.test.ts. The test assumes the app's public key is base64 SPKI DER, which is what the PEM wrapper expects. Confirm this on both iOS and Android with a real enrollment, since a format mismatch is the most common reason verification fails.
What Edge Cases Will Break This in Production?
Most failures come from device changes and key state, not from the cryptography. Plan for each of these before launch.
| Situation | What happens | What to do |
|---|---|---|
| User gets a new phone | Old public key no longer matches | Re-enroll after a normal login |
| User changes enrolled biometrics | The key may stop working, depending on platform and library options | Detect the failure and send them to re-enroll |
| No biometrics enrolled on the device | isSensorAvailable() reports unavailable | Offer a passcode or password path |
| Shared or rugged tablet | One device key does not map to one person | Use passcodes or badges instead |
| Challenge replay | Attacker resends a captured signature | Single-use, short-lived challenges, as above |
| Account deletion | Public key remains on server | Delete stored keys with the account |
Some community forks of the library add options such as invalidating a key when new biometrics are enrolled, so check the README of whichever package and version you install. For the surrounding controls, our native app security checklist for enterprise mobility covers storage, transport, and session handling.
Also write down your purpose, retention rule, and consent flow before you ship. The code limits what you hold, but your counsel decides what your obligations are.
Frequently Asked Questions
Is this the same as using passkeys?
It follows the same idea: a device-bound key pair and a signed challenge. Passkeys (WebAuthn) add standard tooling and cross-device sync, so evaluate them first for new projects.
Why not just store a "biometric enabled" flag?
A flag on the client proves nothing to your server. Without a signature over a fresh challenge, anyone who modifies the app can skip the check.
Can I use simplePrompt() for login?
No. It only confirms the user passed a biometric check on the device and gives your server nothing to verify. Use signature-based login instead.
Where should I store the public keys?
In your user database, linked to the account and device. Keep a device identifier too, so users can revoke a lost phone.
Apptage is a mobile and web app development firm based in South Jordan, Utah. If you are building an app with biometric login or other sensitive features, you can book a discovery call and we will map the design decisions before scoping.
