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:
- Get the type of biometrics supported by the device. If this returns null, biometrics is unsupported and we return early.
- If biometrics is supported, set
isBiometricsSupported
to true and store the type. - 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. - 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:
- Set
isBiometricsLoading
to true. - Store the credentials, identifying our app as
'my-app'
. TheaccessControl
option controls which authentication methods can be used to access these credentials. Theaccessible
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 forKeychain.ACCESS_CONTROL
andKeychain.ACCESSIBLE
enums and confirm that you are using the value that matches your use case. - Next, we store our
'is-biometrics-enabled'
value inAsyncStorage
, so on subsequent app launches, we can know if there are credentials stored on the device without prompting the user. - If all goes well, set
isBiometricsEnabled
to true andisBiometricsLoading
to false. - 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!