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
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:
- Passwordless Authentication With Passkey: How It Works and Why It Matters (Introduction)
- How to Implement Passwordless Authentication with Passkey using React Native and Node.js — Part 1 (Server Side implementation)
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
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.
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.
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