Technology
11 Min.
Read

How to support Apple iCloud Passkeys with WebAuthn on iOS 15 and macOS Monterey

This is the first part of a two-part series on Apple Passkeys. In this article, we will walk you through the creation of a simple web app for registration and authentication using Passkeys on Apple devices. In part 2 of this guide, we will cover adding Apple’s Shiny iOS app to your setup from the guide, demonstrating a seamless user experience across web and mobile.

Target Audience: Developers who want to try out Apple ‘Passkeys’ with their website and / or app and, for that, need to ‘adopt WebAuthn on their server’.


Say hello to Apple’s embracement of WebAuthn

Apple announced at WWDC21 that WebAuthn credentials will be available as “Passkeys” in the iCloud Keychain, as well as the availability of system-wide WebAuthn APIs on iOS, iPadOS, and macOS.

While the WebAuthn API has been available on all major platforms – including iOS and macOS – for some time, Apple’s new “Passkeys in iCloud Keychain” feature is attempting to solve WebAuthn’s biggest remaining pain point. The synchronization of WebAuthn credentials across all devices of a user enables true passwordless accounts that do not need to fall back to less secure authentication or recovery methods like passwords if you want to sign in to a website or app on a new device. Once enrolled, users can sign in with Face ID and Touch ID on all their Apple devices without worrying at all about creating or memorizing a password or becoming the victim of a password-related attack like Phishing.

The other WWDC announcement, system-wide WebAuthn APIs on iOS and macOS, is also very welcome, because the APIs enable apps and websites from the same service (i.e., the same URL) to access the same WebAuthn credentials on a device. Another result of the APIs is that other browsers than Safari (once they implement the new APIs) can access the credentials as well. Until now, only Safari supported system-level WebAuthn credentials on iOS, iPadOS, and macOS. Apple is only catching up here though, as this feature is already present on Windows 10 (“Windows Hello”) and Android.

Adopt WebAuthn on your server

In their WWDC announcement video, Apple demonstrates the creation and seamless synchronization of Passkeys across devices. They even show that WebAuthn works with iOS Apps using the same Passkey. But how to create the server part is left opaque. Actually, it is just an item in their list of “Next steps” without further explanation.

Adopt WebAuthn on your server, will ya?

In this guide, you will:

  • Learn how to set up a server that supports WebAuthn authentication and therefore, Apple Passkeys
  • Create a sample website with WebAuthn registration and authentication
  • Build and run a demo setup showing cross-device, end-to-end passwordless authentication on iOS 15 / macOS Monterey devices
  • Bonus: The demo will also work on Windows 10 and Android 7+, only without the synced Passkeys in iCloud Keychain feature of course

What do you need to implement Passkey login and iCloud sync?

  • Two Apple devices to actually sync the Passkeys, e.g., an iPhone with iOS 15 and a Mac with Monterey. Use Safari on both of them.
  • A web app (we’ll get to that 😀)
  • A WebAuthn / FIDO2 server component (we happily provide the Hanko Authentication API for that 🚀)

Again – in case you are looking for the iOS app case, i.e., sharing Passkeys between apps and websites, this will be the content of the second part of this guide.

Celebrating the ceremonies

Some context first: WebAuthn relies on two ‘ceremonies’, the credential registration and the actual authentication. In the WebAuthn spec, they are called ‘attestation’ and ‘assertion’, but we will stick to registration and authentication.

During registration, a unique public/private keypair is being generated. The private key – aka the Passkey – is stored in the Keychain and the corresponding public key is being stored on the server. In our case at hand, the registration takes place only once, during initial user account registration. In a real world scenario, you would enable your users to add multiple WebAuthn credentials to their account on their profile page, e.g., USB/NFC Security Keys or other non-Apple devices.

Following the registration, whenever a user wants to log in to the service’s website or app, instead of providing a username and password, the user requests authentication with the Passkey, using the WebAuthn protocol. In our demo case, the button will just say “Login”, no other form fields. The user does not even need to provide a username – ain’t that cool?! No more lost usernames!

Access to the Passkey is protected on your device with your preferred mechanism: Face ID, Touch ID, or a PIN. The Passkey itself never leaves your device during registration or authentication, it is only being used locally for creating a digital signature that will be validated with the public key on the server.

Let’s get to work!

Enable Platform Authenticator Syncing

First of all, enable Platform Authenticator Syncing on your Apple devices. In iOS 15, turn on the Syncing Platform Authenticator switch under Settings > Developer. The Developer menu is available on your device when you set it up as a development device in Xcode.

In macOS Monterey, go to Safari > Preferences, click the Advanced tab, and select the “Show Develop menu in menu bar” option. Then enable the Develop > Enable Syncing Platform Authenticator menu item in Safari.

Creating the WebAuthn-enabled web application

We will be using a simple html/JavaScript website with a Go backend for this demonstration. Of course you can use whatever language you are comfortable with on the server side. We choose Go, as you only need a few libraries to get the job done and it is easy to read even if you are not a Go expert.

A quick word on good security practices: This is a demo application. To keep things clean, we will not provide a lot of error handling or input sanitizing. You should not use this code in production environments (YOLO!).

To process WebAuthn requests in a webapp, you need a WebAuthn server component, sometimes also called a FIDO2 Server. This server is dealing with the key management on the application’s behalf, almost like a PKI. There are some open source implementations for that available on GitHub. Surely the fastest way to get WebAuthn up and running is using our Cloud-hosted Authentication API. For that you can create a free account at Hanko Dev Console and set it up according to our Getting Started Guide.


Setting up the project

We assume that you have Go installed. If not, now is the right time to do so. Another tool you need is Git – we just assume that it is installed.

Next you need to clone our repository, which contains a small ready-made web app that uses WebAuthn credentials for authentication:

git clone https://github.com/teamhanko/apple-wwdc21-webauthn-example
cd apple-wwdc21-webauthn-example

So what’s in there?

  • We are keeping most of the code in the main.go file for the sake of simplicity, with two supporting models in a subfolder.
  • In the config subfolder, you will find a config file named config.yaml which you need to fill with your Hanko API credentials.
  • The three html templates needed for the frontend reside in the templates folder. In the index.html and register.html there is JavaScript code, at which we take a look later.


Let’s start with the main.go:

// main.go
package main
 
import (
    "net/http"
    "strings"
 
    "github.com/gin-contrib/sessions"
    "github.com/gin-contrib/sessions/cookie"
    "github.com/gin-gonic/gin"
 
    "github.com/gofrs/uuid"
 
    log "github.com/sirupsen/logrus"
 
    "github.com/teamhanko/hanko-sdk-golang/webauthn"
    "gitlab.com/hanko/apple-wwdc21-webauthn-example/config"
    "gitlab.com/hanko/apple-wwdc21-webauthn-example/models"
)
...

Pretty straight forward: we import the Go http and strings libraries, along with the Gin session middleware, the cookie library and the Gin request router. They enable us to create http endpoints to communicate with and to create cookie-based sessions for signed-in users. 

To create unique ids for our users, we choose UUID and import a library for that.

Last but not least, we need the Hanko Go SDK, the corresponding configuration, and the two supporting models.

The Go app itself has a few http endpoints:

...
  r.Static("/assets", "./assets")    // static assets like images
  r.StaticFile("/favicon.ico", "./assets/favicon.ico") // a favicon :)
  r.StaticFile("/", "./index.html")  // the main screen w/ login button
  r.StaticFile("/register", "./register.html")  // the registration form
 
  r.POST("/registration_initialize", ...   // step 1 for registration
  r.POST("/registration_finalize", ...     // step 2 for registration
 
  r.POST("/authentication_initialize", ... // step 1 for authentication
  r.POST("/authentication_finalize", ...   // step 2 for authentication
 
  r.GET("/content", ...   // the protected content, served after login
  r.GET("/logout", ...    // the logout url
...

Besides some static content, we can see the four endpoints needed for the two WebAuthn ceremonies: registration and authentication. They only accept the POST http method.

You might have noticed the initialize/finalize pattern here: Whenever we are in the WebAuthn context, we first have to do an initialization with the Hanko API. Then we need to communicate with the authenticator (aka your Mac or iPhone) using Hanko’s JavaScript SDK and pass the result to the finalize endpoint.

User signup – the registration ceremony

The first two endpoints handle the registration ceremony. When the user enters the desired username and hits the “Register” button, the JavaScript function do_reg() in our register.html calls the /registration_initialize endpoint of the web app:

// This function will be called by the “Register” button
  async function do_reg(event) {
      event.preventDefault()
      const username = document.getElementById("username").value
      var formBody = [];
      formBody.push(encodeURIComponent("user_name") + "=" + encodeURIComponent(username));
      const regInitResponse = await fetch('/registration_initialize', {
          method: 'POST',
          headers: {
              'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
          },
          body: formBody
      })
 
      if (!regInitResponse.ok) {
          const error = (await regInitResponse.json()).error
          showRegError(error)
      }
 
      const creationOptions = await regInitResponse.json()
...

The endpoint will check the desired username, create a UUID, and return a JSON object which is contained in our JavaScript constant creationOptions. Let’s take a look at the backend code that creates said JSON:

... 
  // Create the request options for the Hanko API
  user := webauthn.NewRegistrationInitializationUser(userModel.ID, userModel.Name)

  authenticatorSelection := webauthn.NewAuthenticatorSelection().
    WithUserVerification(webauthn.VerificationRequired).
    WithAuthenticatorAttachment(webauthn.Platform).
    WithRequireResidentKey(true)

  request := webauthn.NewRegistrationInitializationRequest(user).
    WithAuthenticatorSelection(authenticatorSelection).
    WithConveyancePreference(webauthn.PreferNoAttestation)

  // Get the registration request from the Hanko API with the given 
  // request options
  response, apiErr := apiClient.InitializeRegistration(request)
...

First off, the code above picks up the ID and username. We need them for the call to the Hanko API. Then we set a few parameters for the WebAuthn credentials:

  • User Verification: Required This triggers the authenticator to ask for Face ID, Touch ID or a PIN whenever the new Passkey is to be used. Your device decides which mechanism is active. We want multi-factor authentication!
  • Authenticator Attachment: Platform – We want your Mac or your iPhone as authenticator device. Another option would be to require an USB Security Key for example.
  • Resident Key: True – This feature is also referred to as “Discoverable Credential” and it enables us to authenticate without a username, just by providing the Passkey. Pretty convenient. We want that, so we switch it on!
  • Conveyance Preference: Prefer no Attestation: This determines if we want to receive so called attestation information. Think of it as a certificate about the capabilities of the authenticator. You would be using that in a scenario with advanced security needs, e.g., in an online banking scenario. This is not the case here, so we switch it off.


The Hanko API creates a correctly formatted representation of these parameters for us, which our JavaScript picks up as mentioned above. Our app can now pass them to the browser’s WebAuthn API using Hanko’s JavaScript SDK:

...
      const authenticatorResponse = await hankoWebAuthn.create(creationOptions)
...

The hankoWebauthn.create() function will trigger a native dialogue in Safari to grant permission to create a new Passkey by unlocking your Keychain. Once completed, we POST the authenticator’s response to the backend:

...
      const registrationResponse = await fetch('/registration_finalize', {
          method: 'POST',
          body: JSON.stringify(authenticatorResponse)
      })
...

The backend at /registration_finalize receives this response and calls the Hanko API again, completing the registration ceremony.

...
 // Send the authenticator response to the Hanko API
 r.POST("/registration_finalize", func(c *gin.Context) {
    // Parse the authenticator response
    request, err := 
    webauthn.ParseRegistrationFinalizationRequest(c.Request.Body)
...
    response, apiErr := apiClient.FinalizeRegistration(request)
    // on success create the user session
...

Once this is successful, the browser will be redirected to the /content endpoint of the web app:

...
      if (!registrationResponse.ok) {
          const error = (await registrationResponse.json()).error
          showRegError(error)
      } else {
          location.assign('/content') // redirect on success
      }
...

Well done! You are now registered with your Passkey 🥳

As you have just registered your Passkey, the application now considers you as “signed in”. Because of Apple’s new syncing feature, the Passkey is now already available on your companion device – let’s assume that this is your iPhone.

To move on to the next step, press the “Logout” button in the upper right corner. This takes you to the /logout endpoint, terminating your session, and immediately redirecting you to the start page. Now we can proceed to the second ceremony.

User login – the authentication ceremony

The only thing we need to create the ultimate login experience is: A "Sign in" button 😉 and a rather simple JavaScript function do_auth() to trigger the login process. No need for a separate username field, as we are using the domain name and the UUID as our common identifier behind the scenes. Passkeys are fixed to a specific domain.

Let’s have a look at the first half of the do_auth() function:

async function do_auth(event) {
    ...
    const authInitResponse = await fetch('/authentication_initialize', {
        method: 'POST'
    })
 
    const authOptions = await authInitResponse.json()
    const authenticatorResponse = await hankoWebAuthn.get(authOptions)
...

This function first calls the backend’s /authentication_initialize endpoint, which creates request options like we did during registration. The resulting request options object is passed to Safari’s WebAuthn API using Hanko’s Javascript SDK function hankoWebAuthn.get(authOptions).

The corresponding backend code using the Hanko SDK is rather short:

// Get an authentication request from the Hanko API
r.POST("/authentication_initialize", func(c *gin.Context) {
    // Create the request options
    request := webauthn.NewAuthenticationInitializationRequest().
        WithUserVerification(webauthn.VerificationRequired).
        WithAuthenticatorAttachment(webauthn.Platform)
 
    // Get the authentication result from the Hanko API with the 
    // given request options
    response, apiErr := apiClient.InitializeAuthentication(request)
    if apiErr != nil {
        c.JSON(apiErr.StatusCode, gin.H{"error": apiErr.Error()})
        return
    }
 
    c.JSON(http.StatusOK, response)
})

Just like at registration, a native Safari dialogue will show up. You are being presented with a list of registered Passkeys and can confirm usage with a simple click. Again, the Passkey is being used to sign the request, the key itself will not leave your device! Once a Passkey has successfully been used, the resulting authenticator response is sent to the Hanko API for validation, using the backend’s /authentication_finalize endpoint.

Now to the second half of the do_auth() function in our frontend:

...
    const authenticationResponse = await fetch('/authentication_finalize', {
        method: 'POST',
        body: JSON.stringify(authenticatorResponse)
    })
 
    if (!authenticationResponse.ok) {
        console.log((await authenticationResponse.json()).error)
    } else {
        location.assign('/content') // login successful
    }
}

The backend code takes the response from the authenticator and validates it against the Hanko API. In case of success, a session is being created and the frontend code redirects to our private /content page.

// Send the authenticator response to the Hanko API
r.POST("/authentication_finalize", func(c *gin.Context) {
    // Parse the authenticator response
    request, err := webauthn.ParseAuthenticationFinalizationRequest(c.Request.Body)
...
 
    // Send the authenticator reponse to the Hanko API for validation
    response, apiErr := apiClient.FinalizeAuthentication(request)
    if apiErr != nil {
        c.JSON(apiErr.StatusCode, gin.H{"error": apiErr.Error()})
        return
    }
 
    // If no error occurred during the authenticator response validation,
    // create a session for the given user
    session := sessions.Default(c)
    session.Set("userId", response.Credential.User.ID)
    session.Save()
 
    c.JSON(http.StatusOK, response)
})

That’s it! You are logged in, using only a Passkey that is protected and unlocked by your preferred local authentication mechanism: Face ID, Touch ID or a PIN. Try the login with your iPhone, it just works without registering again – no passwords involved!

See the demo in action

Of course we have prepared a running example for you, just in case. You can find it here.

And you can access the complete source code of this project on our GitHub.

Now, as WebAuthn is a widely adopted internet standard, this demo also works using other browsers and platforms. Give it a try, invite your friends, your mom, and your co-workers to join the fun and feel the difference of a convenient and highly secure login experience. WebAuthn powered by the Hanko API 💪

See you for part 2 of this guide where we will add Apple's Shiny iOS app to our little demo setup. Stay tuned...

If you enjoyed this guide, have a question, or any thoughts how we can improve, please reach out.


Back to overview
Share Post on

More blog posts like this one

Don't miss out on the latest developments in the authentication space and on Hanko's product.