Biometry Authorization using Cognito and React Native

Oleh Deneka
6 min readMar 23, 2024

I thought that I could share with the ones who still have the job and have any ongoing project the way I have built a custom authentication flow in AWS Cognito to let users sign in securely using FaceID or TouchID, not just make users always logged in and navigate them to their account based on true/false from prompt. I couldn’t find anything when I had to implement this, so maybe this guide could help you. No bs, let's jump straight into in. But first, here is a fancy picture from react-native-biometrics docs that describes the flow.

If you’re still reading this and haven’t scrolled straight to the code, as I always do, then here is a small preview of how it works. To see the detailed explanation, copy/paste the code and scroll straight to the bottom.

1. Generate public/private keys to sign our requests using library react-native-biometrics, this is done when a user enables Biometry authentication and a user is logged in.
2. Send a public key to Cognito to store it as a custom user attribute, however, there is a caveat, if you decide to store it as a custom attribute, then only one public key could be stored for a user since there is a limit of around 2000 symbols in one field, and if a user creates second or more from other devices, then he would deactivate previous ones. The solution could be to store it in multiple custom fields, however, it seems more like some mumbo-jumbo, or better, store it in DynamoDB, it is easy to implement and would be the same approach, for me, only one device working with Biometry Authorization seemed okay at the moment, but soon I’ll switch to DynamoDB as well.
3. When the user logs in, the prompt asks him for his biometry, if it’s successful, then a private key is generated based on the payload (some random value).
4. A private key is sent to Cognito using a custom authentication flow to verify if it’s correct.
5. Custom authentication Lambda verifies it using a public key from user attributes (I have found some random fancy code for this on github in one of the issues in react-native-biometrics).

Here is the part in React Native, please don’t judge my code quality, I’m only a senior RN developer. I also have used react-query, cause, well, everyone uses it.

import ReactNativeBiometrics from 'react-native-biometrics'
import {useCognitoUserQuery} from './queries/useCognitoUserQuery'
import {useQuery, useQueryClient} from '@tanstack/react-query'
import {requestHandler, updateBiometryKey, signInWithBiometry} from 'api/auth'

export function useBiometry() {
const rnBiometrics = new ReactNativeBiometrics({
allowDeviceCredentials: false,
})

const queryClient = useQueryClient()

const {data: biometryKeys} = useQuery({
queryKey: ['biometryKeys'],
queryFn: rnBiometrics.biometricKeysExist,
})

const keysExist = biometryKeys?.keysExist

const {data: cognitoUser} = useCognitoUserQuery()
const serverKeyPresent = !!cognitoUser?.['custom:publicKey']?.length

const biometryEnabled = keysExist && serverKeyPresent

const checkBiometry = async () => {
try {
const biometry = await rnBiometrics.isSensorAvailable()
if (!biometry) return undefined
return biometry
} catch (e: any) {
return undefined
}
}

const enableBiometry = async (): Promise<boolean> => {
const result = await checkBiometry()
if (!result?.available) {
return false
}

const keys = await rnBiometrics.createKeys()
if (!keys.publicKey) {
return false
}

const response = await requestHandler(() =>
updateBiometryKey(keys.publicKey)
)

if (!response.success) await rnBiometrics.deleteKeys() //to delete key locally if it was not uploaded

queryClient.invalidateQueries(['COGNITO_CLIENT'])
queryClient.invalidateQueries(['biometry'])
queryClient.invalidateQueries(['biometryKeys'])

return response.success
}

const disableBiometry = async (): Promise<boolean> => {
const {keysDeleted} = await rnBiometrics.deleteKeys()

const response = await requestHandler(updateBiometryKey)

queryClient.invalidateQueries(['COGNITO_CLIENT'])
queryClient.invalidateQueries(['biometry'])
queryClient.invalidateQueries(['biometryKeys'])

return response.success && keysDeleted
}

const logInWithBiometry = async () => {
//IMPORTANT, you need to remember the username when the user logs in, so you could retrieve it now
const username = lastLoggedUser?.username
if (!username || !keysExist) return

//a random string
const payload =
Math.round(new Date().getTime() / 2000).toString() + 'some random payload'

const {success, signature, error} = await rnBiometrics.createSignature({
promptMessage: 'Sign in',
payload: payload,
})

if (success && signature) {
const response = await requestHandler(() =>
signInWithBiometry({
phone: username,
key: signature,
payload: payload,
})
)

return response
} else {
if (error?.includes('User cancellation')) {
return {success: false, error: 'cancel'}
}
}
}

return {
checkBiometry,
biometryEnabled,
enableBiometry,
disableBiometry,
keysExist: keysExist,
canSignIn: keysExist && lastLoggedUser.username,
logInWithBiometry,
}
}

import {Auth} from 'aws-amplify'

export const updateBiometryKey = async (key?: string) => {
const user = await Auth.currentAuthenticatedUser()

if (key)
return await Auth.updateUserAttributes(user, {
['custom:publicKey']: key,
})

return Auth.deleteUserAttributes(user, ['custom:publicKey'])
}

//this function is for easier handling of successful requests outside of react-query
export const requestHandler = async <T, ErrorCode>(
request: () => Promise<T>
): Promise<ApiResponse<T, ErrorCode>> => {
try {
const response = await request()
return {
success: true,
data: response,
error: null,
}
} catch (error: any) {
console.log(error)
return {
success: false,
data: null,
error: error,
}
}
}

type ApiResponse<T, ErrorCode> = SuccessResponse<T> | ErrorResponse<ErrorCode>

type SuccessResponse<T> = {
success: true
data: T
error: null
}

type ErrorResponse<ErrorCode> = {
success: false
data: null
error: {
code: ErrorCode
message: string
name: string
}
}

signInWithBiometry function requires some explanation, more about it below cause it is more about how custom challenge works in Cognito

export const signInWithBiometry = async (userCredentials: {
username: string
key: string
payload: string
}): Promise<CognitoUser> => {
//just in case
await Auth.signOut()

const session = await Auth.signIn(userCredentials.username)

const user = await Auth.sendCustomChallengeAnswer(
session,
userCredentials.key,
{
payload: userCredentials.payload,
}
)
return user
}

When Cognito authenticates the user, when signIn request is called, internally it has its own 9 circles of hell, or Cognito, call whatever you want. It has to run through all Define auth Challenge, Create auth Challenge, Verify auth Challenge. When you call signIn with username and password, then Cognito gives you a response instantly since it's a built-in challenge, but when you need to call custom challenge, then DO NOT send a password cause it will trigger password authentication flow. Send only the username.
The next thing you need is to attach three challenge Lambdas: Define auth Challenge, Create auth Challenge, and Verify auth Challenge.

import {
CreateAuthChallengeTriggerEvent,
DefineAuthChallengeTriggerEvent,
PreSignUpTriggerEvent,
VerifyAuthChallengeResponseTriggerEvent,
} from 'aws-lambda';
import NodeRSA from 'node-rsa';
export const defineAuthChallenge = async (event: DefineAuthChallengeTriggerEvent) => {
console.log('Defining auth challenge');
console.log('event', { event });

if (!!event.request.userAttributes['custom:publicKey']) {
if (event.request.session.length === 0) {
event.response.issueTokens = false;
event.response.failAuthentication = false;
event.response.challengeName = 'CUSTOM_CHALLENGE';
} else if (
event.request.session.length === 1 &&
event.request?.['clientMetadata']?.payload &&
event.request.session[0]?.challengeName === 'CUSTOM_CHALLENGE'
) {
const successAuth = !!event.request.session[0]?.challengeResult;
event.response.issueTokens = successAuth;
event.response.failAuthentication = !successAuth;
} else {
throw new Error('Cannot authenticate user');
}
} else {
event.response.issueTokens = false;
event.response.failAuthentication = true;
}

console.log('modified event', { event });

return event;
};

export const createAuthChallenge = async (event: CreateAuthChallengeTriggerEvent) => {
console.log('Creating auth challenge');
console.log('event', { event });

if (event.request.challengeName !== 'CUSTOM_CHALLENGE') {
return event;
}

event.response.publicChallengeParameters = {};
event.response.privateChallengeParameters = {};
//you need to put in something below
event.response.publicChallengeParameters.securityQuestion = 'whats your biggest regret?';
event.response.privateChallengeParameters.answer = 'becoming react native developer';

return event;
};

export const verifyAuthChallenge = async (event: VerifyAuthChallengeResponseTriggerEvent) => {
console.log('Verifiyng auth challenge');
console.log('event', { event });

const publicKey = event.request.userAttributes['custom:publicKey'];
const payload = event.request?.['clientMetadata']?.payload;
const signature = event.request.challengeAnswer;

const publicKeyBuffer = Buffer.from(publicKey, 'base64');
const key = new NodeRSA();
const signer = key.importKey(publicKeyBuffer, 'public-der');
const signatureVerified = signer.verify(Buffer.from(payload), signature, 'utf8', 'base64');
console.log('signatureVerified', { signatureVerified });

event.response.answerCorrect = signatureVerified;
console.log('modified event', { event });

return event;
};

The code is fairly simple, you need to install node-rsa, or as I’m aware, the functionality that we need comes by default in newer versions of NodeJS.
So how does it work under the hood exactly?
1. You call signIn from front-end without a password
2. Right after that, Cognito calls your Define Auth Challenge Lambda, there are no sessions since it's the first call, so we specify that it is ‘CUSTOM_CHALLENGE’, and IMPORTANT TO KNOW, Cognito bases its response on this lambda response, issueTokens and failAuthentication fields in the event.response. So once we set these values to true, then all 9 circles of hell are interrupted and our user is signed in on a front-end. So if there are no active sessions, we set these values to false.
3. Cognito can’t send the response to the user if challengeName = ‘CUSTOM_CHALLENGE’, so what it does, is it calls Create Auth Challenge Lambda to define your secret question, we don’t need it, cause we have our logic with public/private keys, but we still need to set these values!
4. User receives a user session response to an Auth.signIn request, right after that, we send our private key and payload using sendCustomChallengeAnswer.
5. Verify Auth Challenge Lambda is triggered, which verifies our keys.
6. Define Auth Challenge Lambda is called, but the session is already there, so we look at the challengeResult and issue tokens if it’s true.
7. User is signed in, or not)

Well, that’s it, this article serves as an illustrative example and this approach can be customized more to meet specific user needs. I also have Cognito setup in a way, so users can choose, whether to log in using email or phone number, and Biometry authentication on top of it, of course. If you’re interested in that, I could share with you, how it’s done! Take care!

--

--