Skip to main content

Passkeys

Introduction

Passkeys represent a compelling WebAuthn-based alternative to the timeless combination of "password + second-factor" that we all suffer through.

Passkeys are phishing-resistant, are unique across every website, and can help users maintain account access after device loss.

Additionally, passkeys generated by the three main platform authenticator vendors (Apple, Google, and Microsoft) are automatically synchronized across a user's devices by their respective cloud account. That means there's finally an easy way for users to regain account access if they happen to lose or trade in their device. There's never been a better time to update your authentication to use WebAuthn.

The following options are ones you should set when calling SimpleWebAuthn's methods to ensure that your site is ready for passkeys.

Prerequisites

Below are the three platform authenticator vendors and known minimum versions of their software needed for full passkey support:

Apple: iOS 16, macOS 13 (Ventura, Safari-only)

Google: Android 9+

Microsoft: TBD

FIDO2 security keys are unaffected. They will continue to produce credentials that are hardware bound, and most already support discoverable credentials.

Note that "passkey support" is still "WebAuthn support". Passkeys do not represent a breaking change in how WebAuthn is invoked. Rather they are WebAuthn credentials that can have their private key material synchronized across devices outside the influence of the WebAuthn API. The options below are optimizations for those Relying Parties that want to aim for passkey support as they become more common.

Server

The high-level strategy here is to instruct the authenticator to do the following during registration and authentication:

  1. Generate a discoverable credential. The authenticator must generate and internally store a credential mapped to (rpID + userID).
  2. Perform user verification. The authenticator must provide two authentication factors within a single authenticator interaction.

generateRegistrationOptions()

import { generateRegistrationOptions } from '@simplewebauthn/server';

const options = await generateRegistrationOptions({
// ...
authenticatorSelection: {
// "Discoverable credentials" used to be called "resident keys". The
// old name persists in the options passed to `navigator.credentials.create()`.
residentKey: 'required',
userVerification: 'preferred',
},
});

User verification is "preferred" here because it smooths out potential frictions if a user attempts to use passkeys on a device without a biometric sensor. See "A note about user verification" on passkeys.dev for more context. The actual enforcement of user verification being required for proper passwordless support happens below during response verification.

verifyRegistrationResponse()

const verification = await verifyRegistrationResponse({
// ...
requireUserVerification: true,
});

Make sure to save the transports value returned from @simplewebauthn/browser's startRegistration() method too. Advanced WebAuthn functionality like cross-device auth (i.e. authenticating into a website displayed in Chrome on Windows by using your iPhone) is hard to design good UX around. You can use the browser to figure out when it is available by including each credential's transports in the allowCredentials array passed later into generateAuthenticateOptions(). They will help the browser figure out when a credential might enable a user to log in using new technology that wasn't available before.

Signs that a passkey were created include the following values returned from this method:

  • verification.registrationInfo.credentialDeviceType: 'multiDevice' means the credential will be backed up for use on multiple devices within the same platform vendor cloud ecosystem (e.g. only for use on Apple devices sharing an iCloud account)
  • verification.registrationInfo.credentialBackedUp: true means the private key material has been backed up to the user's cloud account. For all intents and purposes this will always be true when credentialDeviceType above is 'multiDevice'

These values can be stored in the database for a given credential for later reference, to help with understanding rate of adoption of passkeys by your users and adjust your UX accordingly.

generateAuthenticationOptions()

const options = await generateAuthenticationOptions({
// ...
userVerification: 'preferred',
});

User verification is "preferred" here because it smooths out potential frictions if a user attempts to use passkeys on a device without a biometric sensor. See "A note about user verification" on passkeys.dev for more context. The actual enforcement of user verification being required for proper passwordless support happens below during response verification.

verifyAuthenticationResponse()

const authVerify = await verifyAuthenticationResponse({
// ...
requireUserVerification: true,
});

Remembering challenges

The generateRegistrationOptions() and generateAuthenticationOptions() methods both return a challenge value that will get signed by an authenticator and returned in the authenticator's response. The goal is for these challenge values to get passed back into the verifyRegistrationResponse() and verifyAuthenticationResponse() methods respectively as their expectedChallenge arguments.

The question then becomes, "how do I keep track of these challenges between creating the options and verifying the response when the user isn't yet logged in?" This is particularly tricky with passkeys and conditional UI. During this "usernameless" authentication the user is encouraged to present any WebAuthn credential they possess for the site, instead of being told the the discrete list of credentials that they're able to use. If the user can't be known ahead of time, then how can the RP tell the user which credentials they can use for authentication?

caution

The following advice is high-level and avoids referencing specific frameworks and libraries (beyond SimpleWebAuthn) because every project is different. Please read the suggested course of action below, and then consider how it might be adapted to your project's architecture.

Authentication

One technique for tracking the challenge between options generation and response verification is to start a "session" for any user who views your login page.

First, assign a random sessionID HTTP-only cookie when the page first loads. When the user attempts to authenticate, call server's generateAuthenticationOptions() like normal and temporarily store the challenge somewhere (I like to use Redis' setex() and store challenges for five minutes [300000 ms]) with the sessionID as the key and options.challenge as the value. Return the options to the page and await a response.

When the response comes in, look up the challenge by the sessionID cookie and attempt verification, with challenge passed in as expectedChallenge to verifyAuthenticationResponse(). Make sure to delete the challenge so it can't be reused, even if verification fails, to prevent replay attacks! If the authentication response succeeds then log the user in.

Registration

New user registration is a bit unique in that it requires "bootstrapping" the creation of the user's session, ideally after verifying that the new user is not a bot. RP's are thus recommended to seek verification of ownership of a unique "point of contact" that the new user has signed up for outside of your service (the point of contact can also help with account recovery in case of device loss.)

A "magic link" sent to an email address, then, is a particularly simple solution that can perform double duty:

  1. Receiving the magic link confirms that the user is signing up with a valid email account that they have access to, and to which you can send correspondence for account management
  2. Clicking the magic link sends back the random, one-time "authorization code" that initiates a registration ceremony

Once the user clicks the link, you can reasonably assume that the user is not a bot and can therefore register an authenticator to their new account.

Challenge management during registration then looks very similar to the steps outlined above in the Authentication section, with one slight change: the "session" would be established just before calling generateRegistrationOptions(), after verifying that the authorization code in the URL is valid and hasn't been used before.

Browser

There isn't a whole lot that changes in how you call the browser methods if you want to support passkeys, as passkeys don't involve any changes in how WebAuthn is ultimately invoked.

startRegistration()

No changes are required.

startAuthentication()

No changes are required.

...Unless you are interested in leveraging a new capability coming to WebAuthn in almost all modern browsers that's known as "Conditional UI". Conditional UI gives the browser a chance to find and suggest to the user credentials that they can select to then present to you for authentication, all via the browser's native autofill API.

If this interests you, then please see the Conditional UI section of the docs for startAuthentication() as there is a bit of setup required to get it all working.