Biometrics authentication for your mobile app

https://thoughtbot.com/blog/biometrics-authentication-for-your-mobile-app

Biometrics (Face ID, Fingerprint and more) authentication have quickly become something users expect from a modern mobile app. These are fast, familiar, and removes friction that comes with typing passwords on the phone. With React Native, biometrics authentication can be an easy feature or a tricky one depending on the options you take. There are multiple ways to build it, and they do not all offer the same level of security.

In this blog post, I’m going to share three approaches that we evaluated for a recent project. All three approaches can enable a smooth authentication experience, but they come with different tradeoffs in terms of implementation complexity, user experience, and security implications. If you are building this feature for a banking/financial services app, a healthcare app, or a product handling sensitive user data, these tradeoffs matter a lot. In this post, for each approach of biometrics authentication, we’ll explore how they work, where they fall short, and when each makes sense.

Simple Prompt (Approach 1)

The simplest way to add biometrics to your app is to use it as a UI level gate:

  • Prompt the user to authenticate with their biometrics (face or fingerprint).
  • On success, read the auth token from secure storage

This is what simplePrompt in react-native-biometrics does. It triggers the native biometric dialog and returns a boolean indicating success or failure. The critical thing to understand about this approach is that the biometric check and the token retrieval are two separate, unlinked operations. The app calls simplePrompt, gets back true, then independently reads the token from storage. There is no cryptographic connection between them and the biometric result is merely a signal to our code, not a hardware enforced gate on the data itself.

// Pseudocode showing how this works
const success = await biometrics.prompt('Confirm your identity')

if (success) {
  const token = await secureStorage.read('auth_token')
  // log user in
}

Expo vs React Native CLI (Approach 1)

With Expo managed workflow, you’d reach for expo-local-authentication rather than react-native-biometrics. The API is nearly identical, returns a result object with a success boolean, and it works without ejecting or adding any native configuration. To store and retrieve the auth token securely, pair it with expo-secure-store, which writes to the iOS Keychain and Android Keystore under the hood.

With React Native CLI (bare workflow), react-native-biometrics is the natural choice for the biometric prompt. For token storage, the equivalent is react-native-keychain, which gives you direct access to the iOS Keychain and Android Keystore and supports more granular security configuration than expo-secure-store.

Pros (Approach 1)

  • Easy to implement with minimal boilerplate and works out of the box on both platforms
  • Supports Face ID, Touch ID, and fingerprint without extra configuration
  • Works across iOS and Android with a consistent API
  • No backend required
  • Multi device support as each device independently manages its own stored token

Cons (Approach 1)

  • Biometric enforcement happens at the App level, not the OS or hardware level. The biometric check and the token read are two independent steps with no cryptographic coupling between them
  • On a jailbroken or rooted device, tools like Frida can be used to hook into the app at runtime, force simplePrompt to return true, and retrieve the token without the user actually authenticating
  • Does not meet FIDO2 or WebAuthn standards as there is no challenge-response, no server side verification, and no cryptographic proof that a real biometric event occurred on the device
  • The stored token is not hardware locked. Code that bypasses the biometric gate can read it directly from storage
  • Not appropriate for apps handling sensitive financial, healthcare, or regulated data

Cryptographic Key Pair (Approach 2)

This approach upgrades biometrics from a UI gate to something the server can independently verify. The createKeys() method in react-native-biometrics generates an asymmetric key pair. The device holds a private key locked behind biometrics and the server holds the matching public key. Login is just the device signing a challenge and the server verifying the signature.

What makes each piece do its job:

  • Private key — Created inside the device chip at enrollment and never exported. It is only unlocked by a passing biometric scan.
  • Public key — Returned to the app and registered on the server. It is used only to verify signatures.
  • Challenge — A one-time UUID issued by the server at login. The server enforces an expires_at field so late or replayed submissions are rejected.
  • Signature — The chip’s signed challenge sent to the server. The server checks it against the stored public key and issues an access token.

The server never learned your biometric, your private key, or even your password. It just confirmed the right chip signed the right challenge at the right time.

Enrollment

The device generates a key pair inside the hardware chip. The private key never leaves. The public key gets sent to the server.

Private key example: Stays on device, locked by biometrics, never transmitted.

-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEA2a2rwplBQLF29amygykEMmYz0+Ik9e8bKGFCNSGBaGMq
mMV5FBtLGMGMmFnMjQKFMkBHiNIEuAsYhVMqAC+XAAA...
-----END RSA PRIVATE KEY-----

Public key example: Sent to server at enrollment, stored against the user’s account.

-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2a2rwplBQLzamygyk
EMmYz0+Ik9e8bKGFCNSGBaGMqmMV5FBtLGJmFnMjQKF...
-----END PUBLIC KEY-----

Authentication

Step 1: User on the device says: “I want to log in.”

Step 2: Server generates a fresh challenge and sends it back:

{
  "challenge": "a3f8c2d1-9b4e-4f7a-8c3d-2e1f0a9b8c7d",
  "expires_at": "2026-05-01T10:00:30Z"
}

Just a random UUID. Expires in 30 seconds, useless after that.

Step 3: Device prompts Face ID / fingerprint.

Biometric passes → chip unlocks private key → signs the challenge:

{
  "challenge": "a3f8c2d1-9b4e-4f7a-8c3d-2e1f0a9b8c7d",
  "signature": "XrAGWHSPpnmqzQxL3kVt9Yw2NcBdEfUoRiMaTlKsPqJhGvCxZnWbOuIyDe..."
}

This is sent over the network. No password or token or private key. Just the challenge and its signature.

Step 4: Server verifies:

verify(
  publicKey  = <the one stored at enrollment>,
  challenge  = "a3f8c2d1-9b4e-4f7a-8c3d-2e1f0a9b8c7d",
  signature  = "XrAGWHSP..."
) → ✅ true

Server responds:

{
  "access_token": "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyXzEyMyJ9...",
  "expires_in": 720 // 12 hours
}

Expo vs React Native CLI (Approach 2)

expo-local-authentication implements the simple prompt pattern from Approach 1 and exposes no key management APIs. There is no createKeys() or createSignature() equivalent in the Expo SDK, so you cannot implement the cryptographic flow with the standard managed workflow or Expo Go.

With React Native CLI, react-native-biometrics links natively on install and gives you direct access to createKeys(), createSignature(), and deleteKeys(). Your server needs three endpoints: one to store the public key at enrollment, one to issue a challenge, and one to verify the signature and return an access token.

Pros (Approach 2)

  • The biometric check and authentication are cryptographically coupled: a valid signature can only be produced if the biometric passes, enforced by the hardware chip, not App code.
  • Protects against replay attacks: every challenge is a one-time UUID with a short expiry window.
  • Closest to FIDO2 of the three approaches with hardware backed keys, biometric gated signing, and server-side challenge-response compared to the other approaches.
  • Resistant to runtime hooking tools like Frida as any spoofed signature here fails server-side verification because producing a valid signature requires the private key, which never leaves the hardware chip.

Cons (Approach 2)

  • More complex to implement than Approach 1 as it requires backend work for key enrollment, challenge-response, and server-side verification.
  • Multi device support is slightly complex as it requires the server to manage, store, and revoke a separate public key for every enrolled device per user.
  • expo-local-authentication does not support this flow; Expo users need a bare React Native workflow, which adds build infrastructure overhead compared to the other two approaches.

Keychain (Approach 3)

This approach sits between the previous two. Like Approach 1, the auth token lives on the device. Like Approach 2, the biometric check is enforced by the OS at the hardware level, not by the React Native JS code.

When the token is written to the iOS Keychain or Android Keystore with a biometric access control flag, the OS will refuse to decrypt and return the value unless a successful biometric scan happens. The biometric prompt and the token decryption become a single OS-level operation. The App does not call a separate prompt API and then read the value, it just reads the value, and the OS shows the biometric prompt as part of completing that read.

// Pseudocode showing how this works
const token = await secureStorage.read('auth_token', {
  requireAuthentication: true,
})
// log user in

In practice, most apps store only a refresh token behind this gate and keep the access token in memory. The refresh token is long lived but never leaves the device, while the access token is short lived (~12 hours), lives in memory only, and is obtained by exchanging the refresh token at the start of each session. If an access token leaks, it expires quickly on its own. The refresh token, the more powerful credential, is hardware protected and never transmitted after the initial login.

Expo vs React Native CLI (Approach 3)

With Expo, expo-secure-store implements this pattern via requireAuthentication: true. The biometric prompt fires automatically on read, and SecureStore.canUseBiometricAuthentication() lets you check up front whether the device has biometrics enrolled before opting the user into biometric sign in. No native modules to install, and it works inside the managed workflow.

With React Native CLI, react-native-keychain gives the same OS enforced gate but with more granular controls. You pass accessControl: ACCESS_CONTROL.BIOMETRY_CURRENT_SET and securityLevel: SECURITY_LEVEL.SECURE_HARDWARE to setGenericPassword, which guarantees the key is stored securely.

Pros (Approach 3)

  • Biometric enforcement happens at the OS/hardware level, not the App level. A patched App using tools like Frida that bypasses the biometric call still cannot read the token because the OS will not release the encryption key.
  • No backend work required, unlike Approach 2. Only the token storage layer changes, the rest of the auth stack stays the same.
  • Works inside the Expo managed workflow via expo-secure-store, so we don’t need a Bare workflow for this to be implemented.
  • Multi device support is easy. Each device stores its own copy of the token encrypted securely.
  • BIOMETRY_CURRENT_SET invalidates the stored token automatically when a new biometric is enrolled, which is a sensible default for apps handling sensitive data.

Cons (Approach 3)

  • The token still lives on the device. If the access token is compromised through a network breach or server side leak, biometrics on the device cannot help. This can be mitigated by using a refresh token.
  • Token expiry and rotation has to be handled in the App, typically by pairing this with a refresh token pattern explained above.
  • There is no server side proof that biometrics were used. The server sees a normal token request and trusts the client. Approach 2 is the only one of the three that gives the server, cryptographic evidence of a real biometric event.
  • Does not meet FIDO2 or WebAuthn standards as there is no challenge-response and no asymmetric key pair, just hardware backed symmetric storage.

Conclusion

Approach 1 Approach 2 Approach 3
Biometric enforcement App (JS) Hardware chip OS / hardware
Frida / patched app bypass Vulnerable Resistant Resistant
Backend required No Yes No
Server-side biometric proof No Yes No
Replay attack protection No Yes No
Multi-device support Easy Complex Easy
Expo managed workflow Yes No Yes
FIDO2 / WebAuthn alignment No Closest No

If your app is relatively simple (a productivity tool, social app or utility), and biometrics is primarily a convenience feature for a fast login, Approach 1 is a reasonable choice. The implementation is straightforward and the tradeoffs are acceptable when there is no sensitive financial or personal data at stake.

If you are building a banking app, a financial services product, a healthcare app, or anything that handles regulated personal data, the bar is higher. Approach 1 is not appropriate here. Between Approaches 2 and 3, the deciding factor is usually team capability and backend access. Approach 3 requires no backend changes, making it the practical default for most teams. Approach 2 is the strongest option but requires a backend. If your team has the backend access and is comfortable with the added complexity, Approach 2 is the right call for maximum security for the mobile app.

In practice, most apps in sensitive industries start with Approach 3 and move toward Approach 2 if compliance requirements or a security audit demands server side verification.

About thoughtbot

We've been helping engineering teams deliver exceptional products for over 20 years. Our designers, developers, and product managers work closely with teams to solve your toughest software challenges through collaborative design and development. Learn more about us.