Next-Gen User Security: Exploring Passkey Authentication

Next-Gen User Security: Exploring Passkey Authentication

·

10 min read

By Nils Stolpe

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.

const resp = await fetch('/auth/register/start', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({ username }),
})

Once the server has this data and knows of our intent to create a new user with passkey-based authentication it calls generateRegistrationOptions from SimpleWebAuthn, passing in the submitted username, a UUID for the new user account, and a few other options, most importantly rpName and rpId. These two arguments define the Relying Party for our new passkey. rpName is the name of our application and rpId is its domain, and together with userName they will create a unique passkey for this user and application.

const options = await generateRegistrationOptions({
  rpName: RP_NAME,
  rpID: RP_ID,
  userID: id,
  userName: user.username,
  attestationType,
  authenticatorSelection
})

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.

const verificationResp = await fetch('/auth/register/finish', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
  },
  body: JSON.stringify(attResp)
})

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](https://simplewebauthn.dev/docs/packages/server#2-verify-registration-response), along with Relying Party and verification options.

const verification = await verifyRegistrationResponse({
  response: credential,
  expectedChallenge,
  expectedOrigin,
  expectedRPID,
  requireUserVerification: false
})

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.

const authenticate = useCallback(async (autofill = false) => {
    const resp = await fetch('/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.

fastify.get('/auth/login/start', async request => {
    const options = await generateAuthenticationOptions({
      rpID: RP_ID,
      allowCredentials: [],
      userVerification: 'preferred'
    })

    request.session.challenge = options.challenge
    return options
  })

Once received, the browser will take the response and pass it to startAuthentication from SimpleWebAuthn.

let asseResp
try {
  const authOpts = await resp.json()
  asseResp = await startAuthentication(authOpts, autofill)
  //...

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.

  const verificationResp = await fetch('/auth/login/finish', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(asseResp)
  })

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.

  const verification = await verifyAuthenticationResponse({
    response: credential,
    expectedChallenge,
    expectedOrigin,
    expectedRPID,
    authenticator,
    requireUserVerification: false
  })
  // ...
  request.session.user = user
  reply.send(user)

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.