Create your own
Lesson illustration

Biometric Hardware Check for Authentication

In our last lesson, you successfully integrated a basic biometric authentication flow. We used a straightforward plugin to create a component that could gate content behind a biometric prompt. This was a great first step into securing your application.

Today, we will build upon that foundation to gain more granular insight and control. The goal of this lesson is to not only check if biometrics are available but also to determine the specific type of hardware the device offers—be it Face ID, Touch ID, or a fingerprint sensor. This allows for a more tailored user experience, for example, by displaying a message like "Log in with Face ID" instead of a generic "Log in with biometrics."

To achieve this, we will explore a more advanced plugin that provides richer diagnostic information. This process mirrors a common real-world engineering decision: evaluating and sometimes switching libraries to meet more sophisticated requirements.

1. A More Capable Biometrics Plugin

The plugin we used previously, @capgo/capacitor-native-biometric, was excellent for its simplicity. However, for detailed hardware information, we'll turn to @aparajita/capacitor-biometric-auth. This plugin offers a comprehensive API for inspecting the biometric capabilities of a device.

Before we dive into the code, it's worth understanding the features this new plugin provides, especially its checkBiometry() method. The plugin's documentation offers a clear explanation.

GitHub - aparajita/capacitor-biometric-auth: Easy access to native biometric auth APIs on iOS and Android · GitHub

This document details the plugin's API. We'll focus on the parts that describe how to check for biometric availability and what information is returned.

Please read the following sections: Start with the section on Checking availability. Pay close attention to the distinction it makes between isAvailable and strongBiometryIsAvailable, particularly on Android. Next, review the interface definition for CheckBiometryResult. This is the data structure you will be working with, so familiarize yourself with its properties like biometryType and biometryTypes. Finally, look at the BiometryType enum to see the different hardware types the plugin can identify.

As you can see from the documentation, this plugin provides a much richer dataset than a simple boolean, allowing us to build more intelligent UI and logic.

2. Switching and Re-configuring

Let's switch our project to the new plugin. This involves removing the old package and installing the new one. Since you're comfortable with bun, execute these commands from your monorepo root:







# Remove the old plugin
bun remove @capgo/capacitor-native-biometric







# Install the new plugin
bun add @aparajita/capacitor-biometric-auth







# Sync the new plugin's native code
bunx cap sync

The native configuration for this plugin is identical to the last one. We already added the NSFaceIDUsageDescription to Info.plist and the USE_BIOMETRIC permission to AndroidManifest.xml in the previous lesson, so no further changes are needed there. The bunx cap sync command ensures the new native code from @aparajita/capacitor-biometric-auth is correctly linked.

3. Implementing the Advanced Availability Check

Now, let's refactor the BiometricGuard.tsx component from the previous lesson to use the new plugin's API. Our goal is to store the entire result of the availability check and use it to display more specific information to the user.

First, let's look at the theoretical interaction flow for a similar biometric implementation that uses cryptographic keys. The general sequence of generating and using keys is a common pattern in secure systems.

This sequence diagram illustrates a typical biometric setup flow. A client app requests the device to generate a biometrically-protected key pair. The public key is sent to a server to be associated with the user's account, enabling future authentication.

While our current plugin abstracts this away, it's useful to understand the underlying cryptographic principles that make biometric authentication secure.

Now, let's update our React component. We will replace the simple isAvailable boolean with a state variable that holds the entire CheckBiometryResult object.

// src/components/BiometricGuard.tsx (Refactored)

import React, { useState, useEffect } from 'react';
// Import from the new plugin
import { BiometricAuth, BiometryType, CheckBiometryResult } from '@aparajita/capacitor-biometric-auth';
import { isPlatform } from '@ionic/react';

interface BiometricGuardProps {
  children: React.ReactNode;
}

// A helper function to get a user-friendly name for the biometry type
const getBiometryName = (biometryType: BiometryType) => {
  switch (biometryType) {
    case BiometryType.faceId:
      return 'Face ID';
    case BiometryType.touchId:
      return 'Touch ID';
    case BiometryType.fingerprintAuthentication:
      return 'Fingerprint';
    case BiometryType.faceAuthentication:
      return 'Face Unlock';
    case BiometryType.irisAuthentication:
      return 'Iris Scan';
    default:
      return 'Biometrics';
  }
};

const BiometricGuard: React.FC<BiometricGuardProps> = ({ children }) => {
  const [isAuthenticated, setIsAuthenticated] = useState(false);
  // State to hold the detailed biometric info
  const [biometricInfo, setBiometricInfo] = useState<CheckBiometryResult | null>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    if (!isPlatform('capacitor')) {
      console.log('Not running on a native platform. Bypassing biometrics.');
      setIsAuthenticated(true);
      return;
    }

    const checkAvailability = async () => {
      try {
        // Use the new checkBiometry method
        const result = await BiometricAuth.checkBiometry();
        setBiometricInfo(result);
      } catch (e: any) {
        setError('Error checking biometric availability.');
        console.error(e);
      }
    };

    checkAvailability();
  }, []);

  const handleAuthentication = async () => {
    if (!biometricInfo?.isAvailable) {
      setError('Biometric authentication is not available or not configured.');
      return;
    }

    try {
      // Use the new authenticate method
      await BiometricAuth.authenticate({
        reason: 'For easy and secure access',
      });
      // If authenticate() resolves, it was successful
      setIsAuthenticated(true);
    } catch (e: any) {
      // The plugin throws an error on failure
      setError(`Authentication failed: ${e.message}`);
      console.error(e);
      setIsAuthenticated(false);
    }
  };
  
  if (isAuthenticated) {
    return <>{children}</>;
  }

  // Get a user-friendly name for the button
  const biometryName = biometricInfo ? getBiometryName(biometricInfo.biometryType) : 'Biometrics';

  return (
    <div style={{ padding: '20px' }}>
      <h2>Authentication Required</h2>
      <p>Please verify your identity to access this content.</p>
      <button onClick={handleAuthentication} disabled={!biometricInfo?.isAvailable}>
        Authenticate with {biometryName}
      </button>
      {biometricInfo && !biometricInfo.isAvailable && isPlatform('capacitor') && (
        <p style={{ color: 'orange', marginTop: '10px' }}>
          {biometricInfo.reason || 'Biometrics not configured.'}
        </p>
      )}
      {error && <p style={{ color: 'red', marginTop: '10px' }}>{error}</p>}
    </div>
  );
};

export default BiometricGuard;

Key Changes in the Refactored Component:

  1. Imports: We now import BiometricAuth, BiometryType, and CheckBiometryResult from @aparajita/capacitor-biometric-auth.
  2. State: The isBiometricAvailable boolean is replaced with biometricInfo, which can hold the full CheckBiometryResult object.
  3. Availability Check: useEffect now calls BiometricAuth.checkBiometry() and stores the entire result.
  4. Authentication Call: BiometricAuth.authenticate() is used. A key difference from the previous plugin is that it resolves on success and rejects on failure, which aligns well with standard async/await error handling using try...catch.
  5. Dynamic UI: A helper function, getBiometryName, uses the biometryType from our state to create a specific label for the authentication button (e.g., "Authenticate with Face ID"). We also display the reason for unavailability if the check fails.

4. Handling Platform Differences and App State

As you discovered in the documentation, Android's biometric landscape is more fragmented than iOS's.

  • iOS: Life is simple. All supported biometrics (Touch ID, Face ID) are considered "strong." The isAvailable and strongBiometryIsAvailable flags in CheckBiometryResult will always be identical.
  • Android: There's a distinction between "strong" and "weak" biometrics. A device might have face unlock that is considered "weak" and only suitable for unlocking the screen, not for authenticating within apps. The checkBiometry() result reflects this:
    • isAvailable will be true if any enrolled biometry can be used by the app (weak or strong).
    • strongBiometryIsAvailable is only true if an enrolled "strong" biometric (usually fingerprint) is available.
    • The documentation wisely advises relying on these booleans rather than the biometryTypes array on Android, as the hardware might be reported even if it's not usable by apps.

For most security purposes, you'll want to check isAvailable, but for high-security features, you might eventually decide to check strongBiometryIsAvailable.

Responding to Changes

What if a user enables or disables Face ID in their device settings while your app is in the background? Your biometricInfo state would become stale. The plugin provides a listener for the app's resume event to handle this gracefully.

GitHub - aparajita/capacitor-biometric-auth: Easy access to native biometric auth APIs on iOS and Android · GitHub

This section explains how to listen for the app resuming from the background.

Read the short section on Handling app resume to understand the purpose of the addResumeListener method.

You can integrate this into your useEffect hook to ensure your component's state is always fresh.

// Inside BiometricGuard.tsx, within the useEffect hook

useEffect(() => {
    // ... (existing code)

    const checkAvailability = async () => {
        // ... (existing check)
    };

    checkAvailability();

    // Add the resume listener
    const listener = BiometricAuth.addResumeListener(async (result) => {
        console.log('App resumed, re-checking biometrics...');
        setBiometricInfo(result);
    });

    // Cleanup function to remove the listener when the component unmounts
    return () => {
        listener.then(l => l.remove());
    };
}, []);

This addition makes your implementation robust by automatically re-validating biometric status whenever the user returns to the app.

Conclusion

In this lesson, you elevated your biometric implementation from a simple check to a detailed diagnostic. This gives you the power to create a more polished and informative user experience.

Here are the main takeaways:

  • Choosing the right plugin involves balancing simplicity with the need for detailed features.
  • The @aparajita/capacitor-biometric-auth plugin provides a checkBiometry() method that returns a rich CheckBiometryResult object.
  • You can use the biometryType property to identify the specific hardware (Face ID, Touch ID, etc.) and tailor your UI accordingly.
  • Understanding the platform-specific nuances, like the strong vs. weak biometry distinction on Android, is crucial for building a reliable app.
  • Handling app state changes, such as resuming from the background, by using addResumeListener ensures your app's security status remains current.

In the next lesson, we will encapsulate all this logic into a reusable React hook (useBiometricAuth). This will abstract away the complexity of state management, availability checks, and authentication calls, allowing you to secure any component or route in your app with a single, clean line of code.

Can't find a good explanation? Sign up and we'll make it for you

Sign up