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:
- Generate a discoverable credential. The authenticator must generate and internally store a credential mapped to (
rpID
+userID
). - 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: false,
});
requireUserVerification
is set to false
above because many websites can be just fine using passkeys without user verification! The phishing resistant properties of WebAuthn elevates passkeys to a higher level of protection than username+password+2fa, and thus passkeys are not necessarily beholden to the same "multiple factors of auth" rules that came before them.
However some websites, for various regulatory reasons, may require multiple factors of authentication to be provided. If you are a developer of such a website then you should set userVerification: 'required'
when calling generateRegistrationOptions()
, and specify requireUserVerification: true
when calling verifyRegistrationResponse()
.
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 betrue
whencredentialDeviceType
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',
allowCredentials: [],
});
userVerification
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.
allowCredentials
can also be set to []
to allow the user to choose from any discoverable credentials they have for the site when calling startAuthentication()
after the user clicks a "Sign in with a passkey" button. This "flips the script" on the authentication ceremony by allowing the user to generate a WebAuthn response with a registered passkey, which then tells the RP which account the user wants to log into without the user needing to provide an account identifier beforehand! After verifying the response and confirming it recognizes the credential then the RP should create a session for the internal user record that is associated with the response's id
(and/or userHandle
when available.)
Setting allowCredentials: []
when calling generateAuthenticationOptions()
is OPTIONAL if you are using @simplewebauthn/browser's startAuthentication()
method with its second positional useBrowserAutofill
argument set to true
; startAuthentication()
will take care of this for you in this configuration.
See the Conditional UI section of the docs for startAuthentication()
for more information.
verifyAuthenticationResponse()
const authVerify = await verifyAuthenticationResponse({
// ...
requireUserVerification: false,
});
requireUserVerification
is set to false
above because many websites can be just fine using passkeys without user verification! The phishing resistant properties of WebAuthn elevates passkeys to a higher level of protection than username+password+2fa, and thus passkeys are not necessarily beholden to the same "multiple factors of auth" rules that came before them.
However some websites, for various regulatory reasons, may require multiple factors of authentication to be provided. If you are a developer of such a website then you should set userVerification: 'required'
when calling generateAuthenticationOptions()
, and specify requireUserVerification: true
when calling verifyAuthenticationResponse()
.
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?
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:
- 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
- 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.