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

A Full Walkthrough to Passkey Authentication: Server Setup and Integration with SimpleWebAuthn and Fastify

Heritage Holdings Tech Blog
14 min readMar 22, 2024
How to Implement Passwordless Authentication with Passkey using React Native and Node.js

This is the second article in the series dedicated to passwordless authentication with passkey. If you missed the first one, where we introduced the concept of a passkey, discussed its advantages, and explained how it works, you can read it here.

In this article we dive into further technical details and see how it is possible to implement passwordless authentication with passkey, starting with server configuration and implementation. In the next and final article, we’ll explore the implementation and configuration on the mobile client side.

We will create a simple example mobile application from scratch that allows the user to register for the service by associating a new passkey with their email or logging in using a previously registered passkey. Once authenticated, you will be able to access the main page of the app, which will simply allow you to view all the passkeys registered by the user and log out.

An example of passkey registration and authentication with a react native mobile app

This example is composed of:

  • A back-end that exposes some REST APIs and handles authentication and registration, acting as a relying party and service provider.
  • A mobile client that uses the APIs exposed from the back-end to register a new passkey, authenticate, and request a list of registered passkeys (only if authenticated), interacting with the mobile native part to request the creation of a new passkey or authentication.
  • A common module api-schema that contains the schemas to validate the data exchanged between the client and server.

The repository hosting this example is available at https://github.com/heritageholdings/passkey-example. It is a mono-repo implemented with nx, so it contains both the back-end and client code.

Both the back-end and client use TypeScript and effect-ts to easily describe the registration and authentication flow in a functional and type-safe way. The use of the Either and Effect monads helps in composing and handling these asynchronous operations in a structured manner.

Furthermore, this example uses Ngrok to improve the developer experience by offering a free static subdomain and exposing your local server to the internet. This method simplifies the configuration required by Apple and Android’s associated domains and facilitates end-to-end testing with both the developer and release builds.

For a comprehensive installation guide, visit the Ngrok website. After completing the installation and registration, users can secure a free static endpoint from the Ngrok dashboard under Cloud Edge > Endpoints.

Back-end Implementation

Back-end architecture and endpoints to implement passkey registration and authentication

The proposed back-end implementation is based on Node.js, using the fastify framework and the @simplewebauthn/server library for seamless webauthn integration.

To manage the challenge during authentication and registration processes, we use the @fastify/session plugin. This tool aids in session management and stores the challenge in memory. It also creates a new session for each call if the client does not provide a session cookie.

To highlight the successful completion of the authentication process following registration or authentication, a JSON Web Token (JWT) is employed. This JWT is issued to the client, enabling it to authenticate with the back-end and access protected resources. The generation and decoding of JWT tokens are facilitated through the @fastify/jwt plugin.

In order to simulate the registration of users and their associated authenticators, an in-memory database is kept. This allows the back-end to manage user credentials and associated data without the need for a persistent storage solution.

Passkey registration

To allow the back-end to offer clients the ability to register a new passkey (and consequently register a new user), it will expose two endpoints:

  • /webauthn/register/generate-options: A POST endpoint that the client uses to initiate the registration ceremony. It expects to receive a CredentialCreationOptionsRequest as the body and returns a CredentialCreationOptions as the response.
  • /webauthn/register/verify : A POST endpoint that the client uses to verify and complete the registration of a new passkey. It expects to receive a RegistrationResponseJSON as the body and returns a jwt as the response if the registration is correctly verified.
Contextualization of endpoints and back-end logic within the registration ceremony
Contextualization of endpoints and back-end logic within the registration ceremony

To begin, define the endpoint and register the handlers that will be executed when it is invoked.

fastify.post(
'/webauthn/register/generate-options',
registerGenerateOptionsHandler()
);

fastify.post('/webauthn/register/verify', registerVerifyHandler());

Now, we need to define the logic that will be executed when a client calls these endpoints. We will start with the registerGenerateOptionsHandler:

export const registerGenerateOptionsHandler =
(): RouteHandlerMethod => async (request, reply) => {
const createNewRegistrationChallenge = pipe(
S.parseEither(CredentialCreationOptionsRequest)(request.body), // (1)
Either.flatMap(checkIfUserExists(request.usersDatabase)), // (2)
Either.map(prepareRegistrationOptions(request.webauthnConfig)), // (3)
Effect.flatMap((options) =>
pipe(
Effect.tryPromise(() => generateRegistrationOptions(options)), // (4)
Effect.tap((options) =>
request.session.set('registrationChallenge', options.challenge) // (5)
)
)
)
);

const operationResults = await Effect.runPromiseExit(
createNewRegistrationChallenge
); // (6)
Exit.match(operationResults, {
onFailure: handleErrors(reply), // (7)
onSuccess: (credentialCreationOptions) =>
reply.send(credentialCreationOptions), // (8)
});
};

This function primarily manages the generation of registration options. This includes parsing the payload, verifying if the user exists, preparing options, generating options, and storing the challenge in the session.

[1] First, we check if the request.body of the POST has the correct shape as defined in CredentialCreationOptionsRequest and if it contains the email field.

[2] In this simple example, we only allow one passkey per user and do not permit the registration of multiple passkeys. For simplicity, we maintain an in-memory database with all users and use this database to check if the user exists.

const checkIfUserExists =
(userDatabase: UsersDatabase) => (body: { email: string }) => {
return userDatabase.getUser(body.email)
? Either.left(new UserAlreadyExistsError())
: Either.right(body.email);
};

[3] Generate the necessary options for the simplewebauthn library to create the registration options that need to be returned to the client.

const prepareRegistrationOptions =
(config: WebauthnConfigOptions) =>
(email: string): GenerateRegistrationOptionsOpts => ({
rpName: config.rpName,
rpID: config.rpId,
userID: email,
userName: email,
// Don't prompt users for additional information about the authenticator
// (Recommended for smoother UX)
attestationType: 'none',
authenticatorSelection: {
// "Discoverable credentials" used to be called "resident keys".
residentKey: 'required',
userVerification: 'preferred',
},
// Support the two most common algorithms: ES256, and RS256
supportedAlgorithmIDs: [-7, -257],
});

[4] The library simpleWebAuth creates the credential registration options that need to be sent back to the client.

[5] The challenge is stored in the user session so that it can be used later during the validation phase to ensure the signed response is valid.

[6] The createNewRegistrationChallenge process is executed.

[7] In the event of an unsuccessful execution, an error status code is returned based on the exception.

[8] If the execution is successfully completed, the credential registration options are returned to the client as the response body.

This completes the execution of the first phase of the registration ceremony. The client now will receive the credential registration options and will use these to generate a new passkey and sign the challenge.

To verify the creation of a new passkey, we need to implement the registerVerifyHandler function:

export const registerVerifyHandler =
(): RouteHandlerMethod => async (request, reply) => {
const verifyRegistration = Effect.Do.pipe(
Effect.bind('registrationResponse', () =>
S.parseEither(RegistrationResponseJSON)(request.body)
), // (1)
Effect.bind('expectedChallenge', getExpectedChallenge(request.session)), // (2)
Effect.tap(() => request.session.set('registrationChallenge', undefined)), // (3)
Effect.bind(
'verifyRegistrationResponseOpts',
({ registrationResponse, expectedChallenge }) =>
Effect.succeed(
prepareVerifyRegistrationResponse(
registrationResponse,
request.webauthnConfig,
expectedChallenge
)
)
), // (4)
Effect.flatMap(
({ registrationResponse, verifyRegistrationResponseOpts }) =>
pipe(
Effect.tryPromise(() =>
verifyRegistrationResponse(verifyRegistrationResponseOpts)
), // (5)
Effect.flatMap((result) =>
result.verified && result.registrationInfo
? Effect.succeed(result.registrationInfo)
: Effect.fail(new VerificationFailedError())
), // (6)
Effect.tap((registrationInfo) =>
registerNewAuthenticator(
registrationResponse,
registrationInfo,
request.usersDatabase
)
), // (7)
Effect.map(() =>
request.fastify.jwt.sign({
email: registrationResponse.email,
})
)// (8)
)
)
);

const operationResults = await Effect.runPromiseExit(verifyRegistration);
Exit.match(operationResults, {
onFailure: (error) => {
reply.status(500).send({ message: 'Internal server error' });
}, // (9)
onSuccess: (token) => {
reply.send({ token });
}, // (10)
});
};

This function essentially manages the registration verification process, which includes parsing the payload, verifying the registration response, registering a new authenticator, and responding with a signed JWT token upon success.

[1] Check if the request.body of the POST matches the shape defined in RegistrationResponseJSON and assign this result to the registrationResponse variable.

[2] Retrieve the previously generated challenge from the user’s session and bind it to the expectedChallenge variable.

const getExpectedChallenge = (session: FastifySessionObject) => () =>
Either.fromNullable(
session.get('registrationChallenge'),
() => new InvalidChallengeError()
);

[3] Remove the challenge from the session, as it should be used only once, even if the validation fails, to prevent replay attacks.

[4] Use the registration response, the expected challenge, and the server configuration to generate the argument required by the registration validation function of the simpleWebAuthn library.

const prepareVerifyRegistrationResponse = (
registrationResponse: RegistrationResponseJSON,
config: WebauthnConfigOptions,
challenge: NonNullable<string | Uint8Array | undefined>
): VerifyRegistrationResponseOpts => ({
response: registrationResponse,
expectedChallenge: `${challenge}`,
expectedOrigin: [...config.rpOrigins],
expectedRPID: config.rpId,
requireUserVerification: true,
});

[5] The simpleWebAuth library validates the creation of credentials and returns the validation result.

[6] Check if the verification has succeeded and if the payload registrationInfo, which contains all the authenticator information, has been successfully created.

[7] Save the new user and associate this authenticator with them in the in-memory database.

[8] Generate a new JWT that the client will use to access the protected resources.

[9] If the operation fails, send a 500 Internal Server Error response.

[10] If successful, send the signed JWT token in the response.

The registration ceremony for the new authenticator is now complete. The back-end has saved the new user’s information and their authenticator’s public information. This data will be used to authenticate the user during their next login. Additionally, the client can now utilize the JWT to access protected resources and identify themselves.

Passkey authentication

Similar to the registration ceremony, the back-end will expose two endpoints to allow a client to authenticate a user using an existing passkey.

  • /webauthn/authenticate/generate-options: A GET endpoint that the client uses to initiate the authentication ceremony. It returns a PublicKeyCredentialRequestOptions as response.
  • /webauthn/authenticate/verify : A POST endpoint that the client uses to verify and complete the authentication with an existing passkey. It expects to receive an AuthenticationResponseJSON as body and returns a jwt as response if the registration is correctly verified.
Contextualization of endpoints and back-end logic within the authentication ceremony
Contextualization of endpoints and back-end logic within the authentication ceremony

To begin, define the endpoint and register the handlers that will be executed when it is invoked.

fastify.get(
'/webauthn/authenticate/generate-options',
authenticateGenerateOptionsHandler()
);

fastify.post('/webauthn/authenticate/verify', authenticateVerifyHandler());

Let’s examine the implementation of the authenticateGenerateOptionsHandler:

export const authenticateGenerateOptionsHandler =
(): RouteHandlerMethod => async (request, reply) => {
const createAuthenticationChallenge = pipe(
Effect.tryPromise(() =>
pipe(
prepareAuthenticationOptions(request.webauthnConfig), // (1)
generateAuthenticationOptions // (2)
)
),
Effect.tap((options) =>
request.session.set('authenticationChallenge', options.challenge) // (3)
)
);

const operationResults = await Effect.runPromiseExit(
createAuthenticationChallenge
); // (4)
Exit.match(operationResults, {
onFailure: (cause) => {
reply.status(500).send({ message: 'Internal server error' });
}, // (5)
onSuccess: (credentialRequestOptions) =>
reply.send(credentialRequestOptions),
}); // (6)
};

This function primarily manages the creation of authentication options, including preparing and generating options, and storing the challenge in the session for later verification.

[1] To generate the authentication options required by the simpleWebAuthn library, prepare the argument. Use userVerification: 'preferred' to facilitate the authentication process on devices without biometrics, as explained in https://simplewebauthn.dev/docs/advanced/passkeys#generateauthenticationoptions. Additionally, utilize the discoverability property of the passkey. This allows all registered passkeys within this relying party to be accepted for authentication. Specify this with the allowCredentials: [] parameter.

const prepareAuthenticationOptions = (
config: WebauthnConfigOptions
): GenerateAuthenticationOptionsOpts => ({
userVerification: 'preferred',
rpID: config.rpId,
allowCredentials: [],
});

[2] Generate the necessary options for the simplewebauthn library to create the authentication options that need to be returned to the client.

[3] Store the challenge in the user session so that it can be used later during the validation phase to ensure the signed response is valid.

[4] The createAuthenticationChallenge process is executed.

[5] If the operation fails, send a 500 Internal Server Error response.

[6] If successful, send the generated credential request options in the response to the client.

This completes the execution of the first phase of the authentication ceremony. The client will now receive the public key credential request options and use them to sign the challenge with one of the passkeys associated with the relying party that they possess.

To verify the authentication with an existing passkey, we need to implement the authenticateVerifyHandler function:

export const authenticateVerifyHandler =
(): RouteHandlerMethod => async (request, reply) => {
const verifyAuthentication = Effect.Do.pipe(
Effect.bind('authenticationResponse', () =>
S.parseEither(AuthenticationResponseJSON)(request.body) // (1)
),
Effect.bind('expectedChallenge', getExpectedChallenge(request.session)), // (2)
Effect.tap(() =>
request.session.set('authenticationChallenge', undefined)
), // (3)
Effect.bind('user', ({ authenticationResponse }) =>
Effect.fromNullable(
request.usersDatabase.getUserByAuthenticatorId(
authenticationResponse.rawId
)
)
), // (4)
Effect.bind('authenticator', ({ user, authenticationResponse }) =>
Effect.fromNullable(user.getAuthenticator(authenticationResponse.rawId))
), // (5)
Effect.bind(
'verifyAuthenticationResponseOpts',
({ authenticationResponse, expectedChallenge, authenticator }) =>
Effect.succeed(
prepareVerifyAuthenticationResponse(
authenticationResponse,
request.webauthnConfig,
expectedChallenge,
authenticator
)
)
), // (6)
Effect.flatMap(
({ verifyAuthenticationResponseOpts, authenticator, user }) =>
pipe(
Effect.tryPromise(() =>
verifyAuthenticationResponse(verifyAuthenticationResponseOpts)
), // (7)
Effect.flatMap((result) =>
result.verified && result.authenticationInfo
? Effect.succeed(result.authenticationInfo)
: Effect.fail(new VerificationFailedError())
), // (8)
// Update the counter in the database
Effect.tap(({ credentialID, newCounter }) =>
user.updateAuthenticator(credentialID, {
...authenticator,
counter: newCounter,
})
), // (9)
// sign a JWT token as a response
Effect.map(() =>
request.fastify.jwt.sign({
email: user.email,
})
) // (10)
)
)
);

const operationResults = await Effect.runPromiseExit(verifyAuthentication);
Exit.match(operationResults, {
onFailure: (error) => {
reply.status(500).send({ message: 'Internal server error' }); // (11)
},
onSuccess: (token) => {
reply.send({ token }); // (12)
},
});
};

[1] Check if the request.body of the POST matches the shape defined in AuthenticationResponseJSON and assign this result to the authenticationResponse variable

[2] Retrieve the previous challenge generated in the user’s session and bind it to the expectedChallenge variable.

const getExpectedChallenge = (session: FastifySessionObject) => () =>
Either.fromNullable(
session.get('authenticationChallenge'),
() => new InvalidChallengeError()
);

[3] Remove the challenge from the session, as it should only be used once, even if the authentication validation fails, to prevent replay attacks.

[4] Use the authentication rawId to retrieve the user associated with the authenticator from the in-memory database and assign it to the user variable.

[5] Use the authentication rawId to retrieve the stored authenticator data associated with the rawId from the in-memory database and assign it to the authenticator variable.

[6] Use the authentication response, the expected challenge, the authenticator, and the server configuration to generate the argument required by the authentication validation function of the simpleWebAuth library.

const prepareVerifyAuthenticationResponse = (
authenticationResponse: AuthenticationResponseJSON,
config: WebauthnConfigOptions,
challenge: NonNullable<string | Uint8Array | undefined>,
authenticator: Authenticator
): VerifyAuthenticationResponseOpts => ({
response: authenticationResponse,
expectedChallenge: `${challenge}`,
expectedOrigin: config.rpOrigins,
expectedRPID: config.rpId,
authenticator: authenticator,
requireUserVerification: true,
});

[7] The simpleWebAuth library validates the client response by checking it against the expected challenge and returns the verification result.

[8] Check if the verification has succeeded and if the payload authenticationInfo, which contains all the updated authenticator information, has been successfully created.

[9] Update the counter for the authenticator in the database according to the authentication response.

[10] Generate a new JWT that the client will use to access the protected resources.

[11] If the operation fails, send a 500 Internal Server Error response.

[12] If successful, send the signed JWT token in the response.

The authentication ceremony is now complete and the back-end has verified that the user who wishes to identify themselves has a valid registered passkey that matches the one used to sign the challenge. Similar to the registration process, the client now has a JWT token that they can use in subsequent calls to access resources reserved for identified users.

Apple and Google domain association

To enable access to the APIs we’ve created from our iOS or Android mobile app, there’s one final step. We must establish a secure link between the domain and our app. This informs both Apple and Google that the APIs exposed by our domain are genuinely linked to our mobile app.

Apple

In order to associate our domain with the mobile app, we need to expose a JSON apple-app-site-association at the endpoint /.well-known/apple-app-site-association. This JSON should contain the webcredentials field, which should contain an array of strings in the format <Application Identifier Prefix>.<Bundle Identifier>.

fastify.get('/.well-known/apple-app-site-association', async (request) => {
return {
applinks: {},
webcredentials: {
apps: [`${request.webauthnConfig.iosTeamId}.com.passkey.example`],
},
appclips: {},
};
});

The Application Identifier Prefix is the team ID, which can be retrieved from https://developer.apple.com/account.

The Bundle Identifier is a unique ID assigned to each iOS mobile application. It can be retrieved from Xcode under the Signing & Capabilities > All tab:

Signing & Capabilities tab in Xcode

In this example, we use the iosTeamId (read from the environment file) as the Application Identifier Prefix and com.passkey.example as the Bundle Identifier.

This will automatically enable the association. When a user installs the app, iOS attempts to download the associated domain file and verify the domains in your entitlement.

Google

For Google, we also need to expose a JSON to link our domain to the app, even though the required configuration differs from that of Apple’s.

Specifically, we need to expose an assetlinks.json at the endpoint /.well-known/assetlinks.json.

fastify.get('/.well-known/assetlinks.json', async (request) => {
return [
{
relation: [
'delegate_permission/common.handle_all_urls',
'delegate_permission/common.get_login_creds',
],
target: {
namespace: 'android_app',
package_name: 'com.passkey.example',
sha256_cert_fingerprints: [
request.webauthnConfig.androidCertFingerprint,
],
},
},
];
});

The package_name is the applicationId declared in the app's build.gradle file.

The sha256_cert_fingerprints is an array of SHA256 fingerprints of your app’s signing certificate. Note that multiple fingerprints are supported to allow the authentication of both release and debug builds. The release fingerprint can be found on the Google Play Console under Release > Configuration > App signing, while the development fingerprint can be obtained by running the command keytool -list -v -keystore app/debug.keystore, pointing to the app keystore. In the command output, you can find the needed value under Certificate fingerprints > SHA256. In our example, we use androidCertFingerprint (read from the environment file) for sha256_cert_fingerprints and com.passkey.example for package_name.

Service example

To complete our example of the relying party back-end, we’ve included a protected endpoint. This specific endpoint can only be accessed by an authenticated user, and the client must provide a valid JWT bearer token:

  • /profile : A GET endpoint that requires authentication, returning the user's profile and the list of associated authenticators.
fastify.get('/profile', async function (request, reply) {
try {
await request.jwtVerify();
const sessionEmail = request.user.email;
return {
email: sessionEmail,
authenticators: request.usersDatabase
.getUser(sessionEmail)
?.getAllAuthenticators()
.map((authenticator) => ({
credentialID: base64url.encode(
Buffer.from(authenticator.credentialID)
),
})),
};
} catch (err) {
reply.send(err);
}
});

Back-end configuration

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

In our example repository, you need to set the .env file with the correct values (the layout is provided in .env.example). Let's examine what these values correspond to:

  • WEBAUTHN_RPID: The relying party identifier should be a valid domain string. If you are following the ngrok approach, this is your ngrok domain.
  • WEBAUTHN_RPNAME: The relying party name could be any string that represents the relying party.
  • WEBAUTHN_RPORIGIN: The relying party origin, composed of the https schema with the WEBAUTHN_RPID.
  • WEBAUTHN_ANDROID_CERT_FINGERPRINTS: The list of SHA256 fingerprints of the app’s signing certificate. As discussed earlier, this value is crucial for correctly exposing the domain association endpoint /.well-known/assetlinks.json and also for validating the Android native origin. While iOS and browsers use the relying party origin to generate the response payload, Android utilizes the cert fingerprint. The back-end needs to know this to validate the response. The simplewebauthn library allows multiple origins to be accepted from the client. We use this option to include the Android native origin, along with the base relying party origin. Before adding the native Android origin in the format of android:apk-key-hash:${androidCertFingerprint}, we need to perform some conversions. The base value, obtainable from the Play Store or the keytool command, is in hex byte format. We need to convert this to base64url.
// Convert from hex string to base64url
const androidCertFingerprint = pipe(
Option.fromNullable(process.env.WEBAUTHN_ANDROID_CERT_FINGERPRINTS),
Option.map((fingerprint) => fingerprint.replace(new RegExp(':', 'g'), '')),
Option.map((fingerprint) => Buffer.from(fingerprint, 'hex')),
Option.map(base64url.encode),
Option.getOrUndefined
);

const mergedOrigins = [
...(rpOrigins ? rpOrigins : []),
...(androidCertFingerprint
? [`android:apk-key-hash:${androidCertFingerprint}`]
: []),
];
  • WEBAUTHN_IOS_TEAM_ID: This is the Apple Team ID of the certificate used to sign the app. In the previous section, we discussed how to obtain it. It is used to correctly expose the domain association endpoint at /.well-known/apple-app-site-association.

In the next article, we’ll explore the mobile implementation and necessary configurations to implement passwordless authentication with React Native.

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.