How to Implement Passwordless Authentication with Passkey using React Native and Node.js — Part 2

A Full Walkthrough to Passkey Authentication: Mobile Client Setup and Integration with Expo and react-native-passkey

Heritage Holdings Tech Blog
7 min readApr 5, 2024
How to Implement Passwordless Authentication with Passkey using React Native and Node.js — Part 2

This is the third and last article in the series dedicated to passwordless authentication with passkey. If you missed the previous ones you can find them here:

In this article we dive into further technical details and see how it is possible to implement passwordless authentication with passkey, continuing with mobile client configuration and implementation. The repository hosting this example is available at https://github.com/heritageholdings/passkey-example.

Mobile Client Implementation

Mobile client used libraries: React Native, TypeScript, effect-ts, react-native-passkey, react-navigation and api-schema

The proposed client implementation is based on React Native. This approach enables a single implementation for both Android and iOS. Additionally, it allows for the use of TypeScript and the sharing of common code with the backend, such as the api-schema module, which contains schemas to validate data exchanged between the client and server.

The main library that helps to implement the passkey authentication is react-native-passkey. This library implements native authentication for both iOS and Android, providing a unified TypeScript interface for developers across both ecosystems.

This library will be used to create the passkey module within the app, which allows the orchestration of the react-native-passkey library with back-end requests.

Mobile app navigation flow

In addition to this, the app is very simple and is organized into different screens with react-navigation:

  • AuthenticationScreen: The initial screen allows the user to register by creating a new passkey or to authenticate with an existing one.
  • HomeScreen: Once the user is authenticated, this screen displays a list of registered passkeys and allows the user to log out.

There is a single main navigator (RootNavigator) Which uses a jotai in-memory atom. This atom stores the JWT token when the user is authenticated, and it's used by the navigator to conditionally render the appropriate screens.

Passkey registration

From the client’s perspective, implementing the registration flow involves coordinating the API call exposed by the back-end with the native request to the authenticator, in order to generate a new passkey from the received JSON. Let’s examine how this process occurs in the mobile app’s passkey registration registerPasskey function:

export const registerPasskey = (email: string) => {
return pipe(
email,
axiosGenerateRegistrationOptions, // (1)
Effect.map((response) => response.data), // (2)
Effect.flatMap(S.parseEither(CredentialCreationOptions)), // (3)
Effect.map(convertCredentialCreationOptionsToReactNativePasskeyOptions), // (4)
Effect.flatMap(nativeRegisterPasskey), // (5)
Effect.map(convertToRegistrationResponse(email)), // (6)
Effect.flatMap(axiosVerifyRegistrationOptions), // (7)
Effect.map((response) => response.data), // (8)
Effect.flatMap(S.parseEither(JwtTokenResponse)) // (9)
);
};

[1] Invoke the back-end endpoint POST webauthn/register/generate-options by including the email for which you want to register a new passkey in the payload.

[2] Extract the data received from the back-end.

[3] Parse the payload returned by the server, checking if the shape is the one defined by CredentialCreationOptions.

[4] Convert the received challenge from base64url to base64. In the current implementation, the react-native-passkey library expects the challenge in base64 format, while the Level 3 WebAuthn specification recommended format is base64url.

const convertCredentialCreationOptionsToReactNativePasskeyOptions = (
options: CredentialCreationOptions
): PasskeyRegistrationRequest => ({
...options,
challenge: base64url.toBase64(options.challenge),
});

[5] Invoke the native register method of react-native-library, passing the PasskeyRegistrationRequest payload to request the creation of a new passkey.

const nativeRegisterPasskey = (request: PasskeyRegistrationRequest) =>
Effect.tryPromise({
try: () => Passkey.register(request),
catch: parsePasskeyError,
});

[6] Convert all the base64 values returned by the react-native-passkey library to base64url. This is the reverse operation of what we did in the 4th step. Currently, the react-native-passkey library returns base64 values, while the level 3 WebAuthn specification recommends using base64url to exchange these values.

const convertToRegistrationResponse =
(email: string) =>
(result: PasskeyRegistrationResult): RegistrationResponseJSON => ({
...result,
id: base64url.fromBase64(result.id),
rawId: base64url.fromBase64(result.rawId),
response: {
...result.response,
attestationObject: base64url.fromBase64(
result.response.attestationObject
),
clientDataJSON: base64url.fromBase64(result.response.clientDataJSON),
},
clientExtensionResults: {},
type: 'public-key',
email,
});

[7] Send the RegistrationResponseJSON to the back-end by invoking POST webauthn/register/verify.

[8] Extract the data field from the backend response payload.

[9] Parse the payload returned by the server, checking if the shape is the one defined by JwtTokenResponse.

After the registration ceremony, the client receives a JWT token. This token can be used for identification and accessing protected endpoints.

As seen, the process is quite simple, once you understand the base64url > base64 > base64url conversions that need to be performed for interoperability with the library.

Passkey Authentication

From the client’s point of view, authentication is almost similar to registration, with the difference being that this time the client will orchestrate different endpoints and use the payload to sign the challenge with an existing passkey. This is implemented in the authenticatePasskey function:

export const authenticatePasskey = () =>
pipe(
axiosGenerateAuthenticationOptions(), // (1)
Effect.map((response) => response.data), // (2)
Effect.flatMap(S.parseEither(PublicKeyCredentialRequestOptions)), // (3)
Effect.map(convertToReactNativePasskeyOptions), // (4)
Effect.flatMap(nativeAuthenticatePasskey), // (5)
Effect.map(convertToAuthenticationResponseJSON), // (6)
Effect.flatMap(axiosVerifyAuthenticationOptions), // (7)
Effect.map((response) => response.data), // (8)
Effect.flatMap(S.parseEither(JwtTokenResponse)) // (9)
);

[1] Invoke the back-end endpoint GET /webauthn/authenticate/generate-options.

[2] Take the data received from the back-end.

[3] Parse the payload returned by the server, checking if the shape is the one defined by PublicKeyCredentialRequestOptions. This is the payload that contains all the required information to authenticate the user with a passkey, including the challenge that should be signed.

[4] For the same reason explained in the registration phase, we need to convert the challenge from base64url to base64.

const convertToReactNativePasskeyOptions = (
options: PublicKeyCredentialRequestOptions
): PasskeyAuthenticationRequest => ({
...options,
challenge: base64url.toBase64(options.challenge),
});

[5] Invoke the native authenticate method of react-native-library, passing the PublicKeyCredentialRequestOptions payload to request identification using a passkey that's registered with the relying party.

[6] In this case, it’s also necessary to convert all the base64 values returned by the react-native-passkey library to base64url to provide the backend with the payload in the expected format.

const convertToAuthenticationResponseJSON = (
response: PasskeyAuthenticationResult
): AuthenticationResponseJSON => ({
...response,
id: base64url.fromBase64(response.id),
rawId: base64url.fromBase64(response.rawId),
response: {
clientDataJSON: base64url.fromBase64(response.response.clientDataJSON),
authenticatorData: base64url.fromBase64(
response.response.authenticatorData
),
signature: base64url.fromBase64(response.response.signature),
},
clientExtensionResults: {},
type: 'public-key',
});

[7] Send the AuthenticationResponseJSON to the back-end by invoking POST webauthn/authenticate/verify.

[8] Extract the data field from the backend response payload.

[9] Parse the payload returned by the server, checking if the shape is the one defined by JwtTokenResponse.

Similar to the registration process, the client receives a JWT token at the end of the authentication process. This token can be used for identification and accessing protected endpoints.

The implementation of all flows is now complete. However, before executing the code, we also need to apply some necessary configurations to the mobile component.

Mobile client configuration

iOS requires the addition of the associated domains entitlement webcredentials:our.backend.domain. Since our example employs Expo in prebuild mode, this configuration needs to be included in the app.config.js file.

module.exports = ({ config }) => {
if (!process.env.EXPO_PUBLIC_BACKEND_DOMAIN) {
throw new Error(
'EXPO_PUBLIC_BACKEND_DOMAIN environment variable is missing, please add it to .env file'
);
}

return {
...config,
ios: {
...config.ios,
associatedDomains: [
`webcredentials:${process.env.EXPO_PUBLIC_BACKEND_DOMAIN}`,
],
},
};
};

When Expo generates the native code, it automatically creates the .entitlements file using the value provided in the EXPO_PUBLIC_BACKEND_DOMAIN environment variable.

If Expo prebuild is not used, it can be added through Xcode by navigating to Signing & Capabilities > Associated Domains.

For Android, ensure that the target SDK version is 34. This is because the react-native-passkey library uses the native Android library androidx.credentials that will only compile against the Android 14 Beta 1 SDK or higher.

Conclusion

In this series of articles, we discussed the implementation of passkey authentication using Node.js and React Native, detailing all the necessary steps for successful end-to-end authentication.

How to implement passkey authentication with react native in 8 steps cheat sheet

While configuring both the server and the mobile client requires some attention, the implementation of the authentication logic is fairly straightforward with the simplewebauthn and react-native-passkey libraries.

Compared to implementing a web client, which is almost automatic with the support of the @simplewebauthn/browser library, developing on React Native requires additional steps. Special attention must be paid to conversions between base64 and base64url.

Despite these additional considerations, using React Native allows us to maintain a single codebase for authentication on both iOS and Android platforms, and it also enables code sharing with the backend.

Finally, keep in mind that the proposed example includes certain simplifications to improve readability and comprehension. It’s not intended for production use. To adapt the code for production environments, you would need to enhance it according to best practices and security standards!

Written by Fabrizio Filizola and Heritage Holdings Engineering Team

--

--

Heritage Holdings Tech Blog

Heritage is a private investment platform built by families for families. Our blog shares engineering challenges, culture, values, best practices, and more.