Implementing a Biometrics Hook in React Native

Implementing a Biometrics Hook in React Native

Biometrics is a must-have for any React Native app. Users expect to be able to authenticate with your app using all the methods offered by their device. There are several great npm libraries that make integrating biometrics into your React Native app easy. However, they only provide a thin wrapper around the native biometrics SDK. It is up to you to design an API that makes working with biometrics simple and maintainable.

I recently had a chance to refactor my project’s implementation of biometrics functionality. Before, we had logic scattered across the app, often using different implementations to achieve the same effect, such as prompting for a login, or clearing the storage. I was able to come up with a reusable hook design that I’m excited to share. Today we’re going to look at writing a hook that will encapsulate all functionality related to biometrics. In the end, you’ll have an easy-to-use hook to use in your components that looks like this:

// login screen
const { isBiometricsEnabled, promptBiometrics, biometricsType } = useBiometrics()

const handleBiometricsLogin = async () => {
    const { username, password } = await promptBiometrics()
    login(username, password)
}

return <Button onPress={handleBiometricsLogin}>
    Login with {biometricsType}
</Button>
// settings screen
const {
    enableBiometrics, disableBiometrics, isBiometricsEnabled,
    isBiometricsLoading, isBiometricsSupported } = useBiometrics()

const handleToggleBiometrics = () =>
    isBiometricsEnabled ? disableBiometrics() : enableBiometrics(username, password)

return (
    <Switch
    disabled={!isBiometricsSupported}
    value={isBiometricsEnabled}
    onValueChange={handleToggleBiometrics}
  />
)

Prerequisites

We’re going to be using React Context to make our biometrics hook available throughout the app. To interact with the native SDKs for biometrics and storage, we’re going to be using two libraries: react-native-keychain and react-native-async-storage. If you’re not already using these modules in your app, go ahead and check out the linked GitHub pages for install instructions.

We’re also going to assume you’re using Typescript, but you can still use these examples in a vanilla Javascript project - you’ll just have to remove the type definitions.

Creating the Context

We need to keep a few pieces of state around, like whether biometrics is enabled or currently loading. We’ll also need a way to access some singleton object that will contain our state (eg. isBiometricsEnabled) and our methods (eg. enableBiometrics). Sounds like a perfect use case for React Context! Let’s get started by defining the type for our context.

interface IBiometricsContext {
  isBiometricsSupported: boolean
  isBiometricsEnabled: boolean
  isBiometricsLoading: boolean
  biometricsType: Keychain.BIOMETRY_TYPE | null
  enableBiometrics: (username: string; password: string) => Promise<void>
  disableBiometrics: () => Promise<void>
  promptBiometrics: () => Promise<false | Keychain.UserCredentials>
}

Then we can instantiate the context object. Feel free to ignore type errors here. We are going to create another default values object that will override this one when we create the provider.

const BiometricsContext = createContext<IBiometricsContext>({
  isBiometricsSupported: false,
  isBiometricsEnabled: false,
  isBiometricsLoading: true,
  biometricsType: null,
  enableBiometrics: async () => undefined,
  disableBiometrics: async () => undefined,
  // @ts-ignore
  promptBiometrics: () => {}
})

Now we have our context object, but it’s not of much use to us until we create a provider to go along with it.

Creating the Provider

We’re now going to be implementing the logic behind our Provider. All of the following code snippets belong inside the body of our BiometricsProvider functional component:

export const BiometricsProvider: FC = ({ children }) => {
  // ...
}

If this is confusing, you may find it helpful to follow along with the file containing the end result, which you can find here.

Setting up our state

Since this is a regular react component, we can store our state in a simple useState.

const [isBiometricsSupported, setBiometricsSupported] = useState(false)
const [biometricsType, setBiometricsType] =
    useState<Keychain.BIOMETRY_TYPE | null>(null)
const [isBiometricsEnabled, setBiometricsEnabled] = useState(false)
const [isBiometricsLoading, setBiometricsLoading] = useState(true)

If this is confusing, you may find it helpful to follow along with the file containing the end result, which you can find here.

Notice the initial state of isBiometricsLoading is true. We need to do some initial setup when the provider mounts, after which we will set it to false.

Initialization

We are going to use our trusty useEffect with an empty dependency array to perform some initialization logic when the provider mounts.

useEffect(() => {
  const init = async () => {
    // 1
    const type = await Keychain.getSupportedBiometryType()
    if (!type) {
      setBiometricsLoading(false)
      return
    }
    // 2
    setBiometricsSupported(true)
    setBiometricsType(type)
    // 3
    setBiometricsEnabled((await AsyncStorage.getItem('is-biometrics-enabled')) === 'true')
    // 4
    setBiometricsLoading(false)
  }
  init()
}, [])

Let’s walk through this code. First off, we are not allowed to pass an async function to useEffect , so we create one inside the function and fire it at the end. This could also be an IIFE. Next, let’s check out the state updates:

  1. Get the type of biometrics supported by the device. If this returns null, biometrics is unsupported and we return early.
  2. If biometrics is supported, set isBiometricsSupported to true and store the type.
  3. we check the device storage to check if the user has previously enabled biometrics. This might seem strange, so let me explain. There exists a function to check which services we have stored credentials for:Keychain.getAllGenericPasswordServices(). However, it prompts the user for biometrics authentication, because the entries are encrypted. This doesn’t make sense for our scenario - we don’t want to prompt the user to authenticate when they haven’t asked for it. Instead, when the user enables biometrics, we will simply store a value in async storage ('is-biometrics-enabled’) and check it during initialization. You’ll see this implementation shortly.
  4. Lastly, set isBiometricsLoading to false.

Core Logic

Let’s take a look at implementing the core functionality of our hook: enabling and disabling biometrics, and prompting the user for a biometrics authentication. First off, let’s tackle the enable function.

const enableBiometrics = async (username: string; password: string) => {
  // 1
  setBiometricsLoading(true)
  try {
    // 2
    await Keychain.setGenericPassword(username, password, {
      service: 'my-app',
      accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_ANY,
      accessible: Keychain.ACCESSIBLE.WHEN_PASSCODE_SET_THIS_DEVICE_ONLY
     })
    // 3
    await AsyncStorage.setItem('is-biometrics-enabled', 'true')
    // 4
    setBiometricsEnabled(true)
    setBiometricsLoading(false)
  } catch (error) {
    // 5
    setBiometricsLoading(false)
    throw error
  }
}

Let’s walk through this line by line:

  1. Set isBiometricsLoading to true.
  2. Store the credentials, identifying our app as 'my-app' . The accessControl option controls which authentication methods can be used to access these credentials. The accessible option can be used to disable credential access in certain scenarios, such as when the device is locked, or whether the credential should be shared across devices. Review the documentation for Keychain.ACCESS_CONTROL and Keychain.ACCESSIBLE enums and confirm that you are using the value that matches your use case.
  3. Next, we store our 'is-biometrics-enabled' value in AsyncStorage, so on subsequent app launches, we can know if there are credentials stored on the device without prompting the user.
  4. If all goes well, set isBiometricsEnabled to true and isBiometricsLoading to false.
  5. Wrap it all in a try/catch block so we can set isBiometricsLoading to false in case of an error. Then, re-throw the error and let the caller deal with it 😉

Next, let’s take a look at the disable function. This one is pretty straightforward. We simply erase the credentials from Keychain and update our AsyncStorage value. We follow the same error handling strategy used in enableBiometrics.

const disableBiometrics = async () => {
  setBiometricsLoading(true)
  try {
    // erase the stored password
    await Keychain.resetGenericPassword({ service: 'my-app' })
    // update the async storage
    await AsyncStorage.setItem('is-biometrics-enabled', 'false')
    // update the local state
    setBiometricsEnabled(false)
    setBiometricsLoading(false)
  } catch (error) {
    setBiometricsLoading(false)
    throw error
  }
}

Finally, we are ready to implement our promptBiometrics function. This one simply wraps the Keychain call using the proper arguments.

const promptBiometrics = async () => {
  return Keychain.getGenericPassword({
    service: 'my-app',
    authenticationPrompt: {
      title: `Sign into MyApp using ${biometricsType}`
    }
  })
}

Whew! All that’s left to do is assemble everything into the object that will be made accessible by our provider, and return the JSX of the Provider itself.

return (
  <BiometricsContext.Provider
    value={{
      isBiometricsSupported,
      isBiometricsEnabled,
      isBiometricsLoading,
      biometricsType,
      enableBiometrics,
      disableBiometrics,
      promptBiometrics
    }}
  >
    {children}
  </BiometricsContext.Provider>
)

Let’s wrap up this with a bow and export our useBiometrics hook.

export default function useBiometrics() {
  return useContext(BiometricsContext)
}

Don’t forget to add the BiometricsProvider to your root App component as well.

// App.tsx
import { BiometricsProvider } from '~/providers/biometrics-provider'

export default function App() {
    <BiometricsProvider>
        // whatever else was here before...
    </BiometricsProvider>
}

That’s all folks! We have managed to fully encapsulate the logic and state of all biometrics functionality for our application. We can check if biometrics is enabled, prompt for a biometrics login and more, all by using the hook.

This also opens up opportunities for simplified testing - all you would need to do is write a mock hook that uses the same logic but leaves out the parts interacting with native SDKs!

We hope this post allows you to simplify your existing code, or write a new implementation with ease. Please check out the full code sample here. Happy hacking!