Are we about to see the end of password-based authentication?
Passkeys are one of the latest developments in long-running efforts to deprecate or at least minimize password-based authentication and its associated security issues. The FIDO Alliance and the W3C have been working toward this over the years, with passkeys conforming to the Web Authentication standard that was finalized in 2019. Adoption is slowly rolling out, but right now you can authenticate with Google, GitHub, Amazon, Microsoft, Nintendo, and a number of other sites using passkey authentication.
At NearForm we like to stay on top of new trends in tech. With the potential to improve user security and so much industry support behind them, passkeys seemed like they were worth investigating. After research and prototyping, we’ve come up with a bare-bones project that demonstrates passkey authentication. We’ll get into the project later, after a brief introduction to passkeys, but if you’re already familiar with the code and want to get right into it then you can find it here .
So a passkey is an alternative to password-based authentication, but how does it work?
A passkey is a digital credential that conforms to the passkey's standard and is stored by the operating system or browser. Passkeys, like SSH keys, are public/private key pairs.
The private key is stored on your device by the OS or browser and is never sent to a third party.
The public key is sent to a server for registration and authentication purposes.
During user registration, the public key should be persisted on the server along with any other account data so it can be retrieved, returned to a browser, and authenticated against a private key.
Supported across multiple devices and browsers
Since passkeys are tied to a specific device, they at first seem to share the single device-bound aspect of physical security keys. However, passkeys were meant to be used on multiple devices, and Apple and Google allow the same passkey to be synchronized across multiple devices — via iCloud or a Google account, respectively.
Passkeys are synced and shared between the same vendor devices if you're using the same Apple ID or Google account on both devices. This means a passkey created in Chrome will be available on Android, and a passkey created on an iPhone will be available in Safari.
Currently, the only other way to share a passkey across devices is with a password manager, with at least 1Password and BitWarden having rolled out some level of support.
You can’t authenticate with passkeys using Firefox yet, but there are plenty of browser options with Chrome, Safari, Edge, and Opera all offering support. None of the popular Linux distributions support passkeys yet either, but cross-device authentication with a passkey created on a mobile device works in Chrome and Edge.
If passkey adoption increases, as it likely will, support issues should even out. Vendor implementations will be released and further refined, usage patterns will emerge as engineers set up authentication systems, and there will likely be greater interoperability and more options for users to synchronize passkeys across devices.
On to the implementation...
For our proof of concept implementation of passkey creation and authentication, we set up a minimal app using React in the client, Fastify on the server, and Mongo for data persistence. For passkey-specific functionality, we relied on the SimpleWebAuthn browser and server packages to streamline our use of WebAuthn.
The frontend application contains two pages: one that provides public access to registration and authentication forms and another that provides authenticated access to user information after login. The rest of this post will concentrate on those two workflows and the code that makes them possible.
Registration
1. Load the page and add a user name on the registration form.
2. Submit.
3. Choose an authenticator. Your available authenticator options and the functionality of all of those options will differ depending on the platform, browser, and any external device being used.
4. Authenticate with your OS or browser, depending on authenticator choice.
5. Observe that the authentication form has rendered with a success message below it.
Authentication
Authentication has a slightly more complex user flow that is dependent on the availability of conditional mediation and autofill, as well as the user’s choice of interactions.
If conditional mediation is available, the authentication form will render with a Username
field that, when clicked, will display an autocomplete drop-down populated by passkeys that are available for the current domain.
If autofill is not available, the Username
field will not render and the user can access authentication options by clicking the Authenticate
button. Triggering this login flow via the Authenticate
button is still available when autofill is supported.
1. Authenticate
- a. Click into the
Username
field to use autofill. Continue to 2.a. - b. Click the
Authenticate
button to use the browser modal. Continue to 2.b.
2. Choose an available passkeys for the current domain, via autofill or browser modal
- a. Passkeys will appear in the autofill menu. Choose one and continue to 3.
- b. Passkeys will appear in the browser modal. Choose one and continue to 3.
3. Authenticate with your browser or OS to use the passkey.
4. Observe that the browser location is now localhost:3000/user
and that the page has been populated with user data from the account you used to authenticate.
5. Log out and you will have experienced the entire functionality of the demo app. You’ll be redirected to the root page where you can register a new account, or authenticate again with the one you just used!
Overview of registration code
We’ll start looking at some of the code that runs during step 2 of the registration flow, after a request to auth/register/start
has been made. The process is complex and involves two round trips to the server, illustrated by the Registration Workflow diagram. If you need more context while reading through the text, each step in the outline below references a node in the diagram.
Registration Workflow The process kicks off with this POST
request in RegistrationForm
. The request sends a username
from the browser to the start registration endpoint.
After the options have been returned from generateRegistrationOptions
their challenge
value is cached in the session along with userName
and id
so they'll all be available during the next request. But first, this request ends and sends the options
data back with the response.
The browser then receives the options that were just generated and passes them to startRegistration
from the SimpleWebAuthn browser package. This is where our new credentials are created, as startRegistration
calls navigator.credentials.create
internally.
The new credentials and other data returned by startRegistration
are then sent to the finish registration endpoint.
Back on the server, the challenge and user that were added to the session in the last request are both retrieved and passed to SimpleWebAuthn's verifyRegistrationResponse
, along with Relying Party and verification options.
The success state of the verification and (if the call was successful) registration data, including the passkey's public key, are returned from verifyRegistrationResponse
. If the registration has succeeded we persist the user and their registration data in MongoDB, clear the challenge and origin from the session, and send the user back with the response.
The browser receives notification of a successful registration and renders the authentication form. With registration complete, we can now use our new credential to log in.
Overview of authentication code
Like registration, authentication is a multi-step process involving two round trips between the browser and server. The Authentication Flow diagram illustrates the process, and each section below will reference a node in the diagram.
Authentication Workflow Enabling autofill was the trickiest part of pulling this app together. The key to getting it working was to start the authentication process when the form loads instead of waiting on an interaction. This could be on page load, your frontend framework’s render or init lifecycle method, or whenever your application needs to make passkey authentication available.
Since we used React, we start preparing for authentication when our AuthenticationForm component mounts. A useEffect
hook is run and, if WebAuthn autofill is supported, the authenticate
function is called with true
to enable autofill.
If autofill isn’t supported, the call to authenticate
will be triggered later by clicking on the Authenticate
button.
The authenticate
function is where the rest of the client code executes, and as a result is pretty long. I’ll go over it in the sections below.
Starting with a GET request to /auth/login/start
.
Not too much is going on there, and the server is pretty simple too. Authentication options are retrieved from SimpleWebAuthn’s getAuthenticationOptions
. The rpID
identifies the application as the Relying Party.
The challenge at options.challenge
is cached in the session and the registration options are sent back to the browser.
Once received, the browser will take the response and pass it to startAuthentication
from SimpleWebAuthn.
The authenticate
function will wait for startAuthentication
to return before continuing with execution. If this call was triggered on load to enable autofill, it won’t continue until the user has clicked into the Username
field and selected a passkey. If it was triggered by clicking the Authenticate
button it will continue after the user selects an authentication option from the browser modal.
After selecting a passkey startAuthentication
will finish its run and the data it returns is sent back to the server's finish login endpoint in a POST request .
The server code attempts to retrieve the user and verify the authentication response using request data, the challenge stored in the session during the previous request, and Relying Party data. The challenge is then deleted from the session so it can't be reused. If verification is successful the user is deemed authenticated and their data is updated and returned in the response.
When the browser receives a successful response from auth/login/finish
it will send the user to the authenticated /user
URL, where some information on the authenticated account is displayed.
And that's it, the process is complete. The Logout
button will invalidate the current authentication and return to the registration form, where you can try registering or authenticating again.
Conclusion
Passkeys are still a relatively new technology, and password-based authentication won’t be going away immediately. We’ll likely be seeing both side by side for some time, using either or both to access the same account. For the time being, it’s worth knowing how to use and implement them.
Adding passkey support to your application in code is fairly complex given the back-and-forth communication between client and server when registering or authenticating. But once you’ve picked up the pattern you shouldn’t have much trouble using it.
There are also some rough edges in terms of support, synchronization interoperability, and UX. They’ll likely be smoothed out at the vendor level as browsers and operating systems add more robust support and lock down their implementations.
The fact that a lot of big tech is so heavily invested in the passkeys is concerning to some people, so much so that Ars Technica released an explainer article that mostly deals with responding to or rebutting those concerns. One of their clarifications on portability links to an Apple passkey developer explaining that import/export is in the design phase and will definitely be coming. Once it does, and once there are some alternative methods to sync your passkeys, these concerns may be largely addressed.
In the end, if an authentication technology can increase user security and decrease friction, it'll be providing useful functionality. Passkeys seem to be doing both so it's likely they’ll be here to stay.
Insight, imagination and expertly engineered solutions to accelerate and sustain progress.
Contact