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
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.
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
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
: APOST
endpoint that the client uses to initiate the registration ceremony. It expects to receive aCredentialCreationOptionsRequest
as the body and returns aCredentialCreationOptions
as the response./webauthn/register/verify
: APOST
endpoint that the client uses to verify and complete the registration of a new passkey. It expects to receive aRegistrationResponseJSON
as the body and returns ajwt
as the response if the registration is correctly verified.
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
: AGET
endpoint that the client uses to initiate the authentication ceremony. It returns aPublicKeyCredentialRequestOptions
as response./webauthn/authenticate/verify
: APOST
endpoint that the client uses to verify and complete the authentication with an existing passkey. It expects to receive anAuthenticationResponseJSON
as body and returns ajwt
as response if the registration is correctly verified.
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:
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.
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
: AGET
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 thehttps
schema with theWEBAUTHN_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. Thesimplewebauthn
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 ofandroid:apk-key-hash:${androidCertFingerprint}
, we need to perform some conversions. The base value, obtainable from the Play Store or thekeytool
command, is inhex byte
format. We need to convert this tobase64url
.
// 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