Secure Web Authentication – Passkeys & Web Authentication API

If you’re looking for a user-friendly and secure web authentication method, passkeys are the way to go. Learn how to implement two passkey flows using the Web Authentication API and explore the best practices for the process.

When it comes to authentication on the web, passwords are far from the best option, both in terms of security and user experience. Thankfully, there are also a number of passwordless options, like magic links and one-time passwords, and the one that is the focus of this article – passkeys.

In a previous article, we’ve provided a theoretical overview of passwordless authentication methods. This time, we will dive deeper into the code and explain how to implement two passkey authentication flows—the user registration flow and the login flow—by using the Web Authentication API specification.

Passkeys – the method of choice for secure web authentication

Before we get into the Web Authentication API, it’s important to explain how passkeys work. A passkey is a credential, a public-private key pair generated by an authenticator, used to access the application. The private key is stored securely on the user’s device, while the public key is sent to the server. 

Most people already own and use the technology required for the passkey authentication process. For example, we could use a fingerprint reader or a facial recognition system like Apple’s Face ID or Android facial recognition. However, biometric authentication isn’t the only option for using passkeys. There are also software-based solutions like a browser profile, such as a Google Chrome browser profile.

With passkeys, the server does not need to know who the user is specifically, just that they are the same person who created the account, which is verified on the platform itself.

This means that the fingerprint used for verification never leaves the platform, and it is not shared anywhere.

With passkeys, the server does not need to know who the user is specifically, just that they are the same person who created the account, which is verified on the platform itself. This means that the fingerprint used for verification never leaves the platform, and it is not shared anywhere.

A convenient feature is that passkeys can be synced across devices if you are using a large ecosystem like Apple’s (iCloud Keychain) or Google’s (Google Password Manager).

Web Authentication API

WebAuthn (short for Web Authentication API) is an extension of the Credential Management API. It is an API specification that enables applications to use strong and secure authentication methods and secure multi-factor authentication (MFA) without the need for verification via a text message. 

The WebAuthn API allows the end users to authenticate their identity using hardware or software-based authenticators, such as USB security keys or platform authenticators like fingerprint readers, instead of relying solely on passwords. 

To provide secure registration and authentication of accounts, these authenticators rely on public-key cryptography. The user has to associate an authenticator with their account, which generates a public-private key pair. 

This authentication method has a number of benefits, including:

Phishing protection

An attacker who creates a phishing website with a fake login can’t log in as the user because the signature changes with the website’s origin.

Reduced impact of data breaches

The only information an attacker can get from a data breach is the user’s public key, rendering the data worthless to the attacker.

Invulnerable to password attacks

As the passkey is unique to the service, if the attacker obtains one service’s passkey, it is useless for another website.

The Web Authentication API is only available in secure contexts, meaning that the web application must use HTTPS. Using HTTPS means that the connection between the browser and the server is encrypted and secured using digital certificates. This is an industry standard and common practice nowadays.

How to implement Web Authentication API – deep dive into the code

The WebAuthn API uses two methods. To register new user credentials, we use the create method, while the get method is used to retrieve the credentials we created.

In line with this, the registration flow creates the user account with the credentials provided, and the login flow validates them.

Registration flow

To create a set of user credentials, we call the create method on the navigator.credentials object. The method requires a set of options, and one of them is the publicKey. Note that the credentials objects could also be created with the password option. This is usually used to implement automatic login into a web application.

Step 1: Triggering the registration process

In the web application, the user enters a unique identifier (e.g. username, email, or phone number) that will be used for the account. The application then sends the identifier to the backend.

Step 2: Validating the identifier and creating a challenge

The server checks if the account with the provided identifier already exists, and if yes, it stops the flow by returning an error response to the application. If an account with the provided identifier does not exist, it generates a random set of bytes called the challenge. This challenge is used by the browser to sign the credentials. This is how the server makes sure that the credentials haven’t been tampered with. The server can now create a temporary account with the new unique user ID and the challenge and return both properties to the application.

Step 3: Triggering credentials creation

The application receives the response from the server and triggers the navigator credentials creation process. This will trigger a browser flow for creating a passkey, and depending on the options passed to the create method, different options could be presented to the user.

Step 4: Verification of credentials

The user creates the passkey, and the application sends the credentials to the server. The server now needs to verify them. 

After the last step, the account for the user can be created, along with the user session. Let’s dissect the code to uncover all the details:

	const publicKeyCredentialCreationOptions = {
    challenge: Uint8Array.from(challengeStringFromServer, c => c.charCodeAt(0)),
    pubKeyCredParams: [{alg: -7, type: "public-key"}],
    rp: {
        name: "Your application name",
        id: "yourapplicationdomain.com",
    },
    user: {
        id: userIdFromServer,
        displayName: "User name",
        name: "user@email.com",
    },
    authenticatorSelection: {
        authenticatorAttachment: "cross-platform",
        userVerification: "required"
    },
    timeout: 60000,
};

const credential = await navigator.credentials.create({
    publicKey: publicKeyCredentialCreationOptions
});

The publicKeyCredentialCreationOptions contain a set of options that will determine what will be displayed in the passkey creation UI, and describe how the passkey will be created. For example, rp (short for relying party) is used to describe the origin responsible for registering and authenticating the user. This prevents phishing attacks, as it ties the passkey to the origin.

The important part of the options is the challenge. The challenge is a string that comes from the server, converted to an array of 8-bit unsigned integers. Later in the process, the server will have to verify the challenge to make sure that the public key was not tampered with.

The authenticatorSelection is used for describing which types of authenticators are acceptable. For example, by allowing only platform authenticators, the account will be tied exclusively to the device the user is currently using to browse the application. One of the options that could be set is userVerification, which will force the user to verify their identity if set to required. This discourages passkey creation with a user browser profile, for example, and requires some type of verification like a fingerprint or face ID.

Validating the registration data

The response from the navigator.credentials.create is a Promise of CredentialType, or in our case, a PublicKeyCredential, and it looks like this:

	PublicKeyCredential {
    authenticatorAttachment: "platform",
    id: "hJLU5F7Yyrzwnwj4bHmx1iMos4E",
    rawId: ArrayBuffer(20),
    response: {
        clientDataJSON: ArrayBuffer(121),
        attestationObject: ArrayBuffer(306),
    },
    type: "public-key"
}

You should validate that the challenge is the same as provided in the step before, which makes sure that the credentials were not tampered with. You also need to validate that the origin value contains the expected value and that the RP ID matches your website to prevent phishing attacks.

The public key is located in the attestationObject in the authData, along with the metadata of the registration event. If you require user verification, ensure that the user verification flag is set to true. Check that the user presence flag is set to true since user presence is always required for passkeys.

Then, ensure that the credential ID hasn’t been registered for any user.

Verify that the algorithm used by the passkey provider to create the credential is the algorithm you listed. This ensures that users can only register with the algorithms you have chosen to allow.

After the validation is completed, the server should create an account and extract and store the userId, publicKey, and credentialId. The last step in the sequence is for the server to create a session for the user and allow further access to the application.

Login flow

The login process can also be split into a sequence of steps.

Step 1: Triggering the login process

In the web application, the user enters a unique identifier (e.g. username, email, or phone number) that is used for the account. The application then sends the identifier to the backend.

Step 2: Validating the identifier and creating a challenge

The server checks if the account with the provided identifier already exists. If not, it stops the flow by returning an error response to the application. If an account with the provided identifier does exist, it again generates a challenge. This challenge will be used to verify the application login response in the later stage.

Step 3: Triggering credentials verification

The application receives the response from the server and triggers the navigator credentials get method. This will trigger a browser flow for retrieving the passkey.

Step 4: Verification of credentials

The user provides the passkey, and the application sends the credentials to the server. The server then needs to compare the credentials against those stored upon registration. 

After the last step, if the credentials match, the user session should be created. Let’s dive into the code again:

	const publicKeyCredentialRequestOptions = {
    challenge: Uint8Array.from(randomStringFromServer, c => c.charCodeAt(0)),
    allowCredentials: [{
        id: Uint8Array.from(credentialId, c => c.charCodeAt(0)),
        type: 'public-key',
        transports: ['usb', 'ble', 'nfc'],
    }],
    timeout: 60000,
}

const assertion = await navigator.credentials.get({
    publicKey: publicKeyCredentialRequestOptions
});

The challenge is again part of the options, and it should again be received from the server, and passed back baked into the assertion later on. 

The property allowCredentials tells the browser which credentials should be used for the user authentication process. The credentialId retrieved and saved during registration is passed in here. This is useful if a device holds multiple credentials for different accounts because it excludes the ones not tied to the user account. Upon the initial request to the server, the server should provide the credential ID. This improves the user experience for users on a shared device.

Parsing and validating the login data

The response from the navigator.credentials.get is a Promise of CredentialType, or in our case, a PublicKeyCredential, and it looks like this:

	PublicKeyCredential {
    id: 'ADSUllKQmbqdGtpu4sjseh4cg2TxSvrbcHDTBsv4NSSX9...',
    rawId: ArrayBuffer(59),
    response: AuthenticatorAssertionResponse {
        authenticatorData: ArrayBuffer(191),
        clientDataJSON: ArrayBuffer(118),
        signature: ArrayBuffer(70),
        userHandle: ArrayBuffer(10),
    },
    type: 'public-key'
}

Most of the checks employed for registration should also be employed for user authentication. Validate the challenge, origin, RP ID, algorithms used, user verification (if used), and user presence flags. 

The difference between registration and login is that the public key is not part of the response, as it has already been passed to the server in the registration flow. This is done by validating the signature from the response. A signature is a challenge signed with the private key associated with this credential, which is verified with the stored public key upon validation.

After successful validation, the server should create a session for the user and allow further access to the application. It’s good practice to remove the challenge after either successful or failed authentication to prevent replay attacks.

Account recovery

If a user loses access to the device used for registration, they will not be able to access their account anymore. There are a few options to implement in this case. Good practice, or even a must-do in this scenario, is to enable the addition of multiple passkeys for the same account. Don’t forget to add the remove passkey option, so that the user is able to remove unused passkeys.

Using recovery codes to unlock the locked account is another way of dealing with lost passkeys. Recovery codes should be provided to the user in the registration flow, and the user should store them safely. This is good practice from a security standpoint, but users tend to forget or ignore the recovery codes.

One of the options is to use a two-factor authentication account recovery, where the user would have to verify their identity via two authentication factors, like email and text. However, this forces users to enter additional sensitive information, and not all users will be willing to do that.

Passkeys support

Passkeys are supported on all major browsers. To make sure that the passkeys are supported on the user’s browser, you should always check the availability of a platform authenticator using isUserVerifyingPlatformAuthenticatorAvailable and check if the conditional mediation is available with isConditionalMediationAvailable. Conditional mediation allows websites to make a WebAuthn request and autofill the form with the passkey.

	if (
 window.PublicKeyCredential &&
 PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable &&
 PublicKeyCredential.isConditionalMediationAvailable
) {
 // Check if user verifying platform authenticator is available.
 const isWebAuthnSupported = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
 const isConditionalMediationSupported = await PublicKeyCredential.isConditionalMediationAvailable();

 if (isWebAuthnSupported && isConditionalMediationSupported) {
 // Display "Create a new passkey" button
 }
}

Get ready for the passwordless future

It is becoming evident that the path to user-friendly and secure web authentication lies in the implementation of passkeys.

By following the defined standards and best practices for using the Web Authentication API described above, developers can offer a great alternative to passwords and provide the users of their web apps with a seamless authentication flow.