Home | Send Feedback

WebAuthn with Go

Published: 27. September 2024  •  go

In this blog post, I show you how to implement a password-less authentication system with the Web Authentication API (WebAuthn) in Go on the back- and Angular/Ionic on the front end.

The Web Authentication API is a web standard that all modern browsers support. You can check the implementation status on "Can I use".

Web Authentication utilizes public/private key pairs to authenticate a user to a system. One of the problems in the early days of the Web Authentication standard was that the generated key pair was bound to the device that generated the keys. This made it difficult to use because you had to register a new key pair on every device you wanted to use for authentication.

To solve this inconvenience, companies like Apple, Microsoft, and Google added a layer on top of WebAuthn that allows one to store the keys in the cloud and replicate them onto all connected devices. This system is called Passkeys.

You might hear the term Passkeys in articles or presentations referring to both the authentication part (WebAuthn) and the cloud synchronization part (Passkeys).

Currently (September 2024), you can't replicate the keys across the clouds of Apple, Microsoft, and Google, but there are plans to solve. Another solution that I use is storing the keys in a password manager. I use the Bitwarden password manager that synchronizes passwords and WebAuthn keys across different operating systems. Google just recently announced they start synchronizing WebAuthn keys across all supported devices stored in the Google Password Manager. We see progress in this area, and using WebAuthn on multiple devices should be easier in the future.

Terminology

Here are some key terms when working with WebAuthn.

The demo application in this blog post only works with Resident Keys.

WebAuthn SDKs

SDKs for implementing the server-side part of a WebAuthn solution exist for all common programming languages and frameworks. You can find a list of SDKs here:
https://www.corbado.com/blog/best-passkey-sdks-libraries

For the Go application in this blog post, I use this library:
https://github.com/go-webauthn/webauthn

This library does not depend on any framework and can be used with any 3rd party library or with the standard library.

One requirement when implementing a WebAuthn solution, regardless of the programming language and framework is support for storing session data between requests. My preferred library in Go for storing session data is SCS. This demo application works with a session cookie that holds a session ID. The session data is stored in a PostgreSQL database.

For the front end, we don't need a library because the API is built into the browser. But the API returns some values as binary data encoded in CBOR. Because it's a bit easier to work with JSON data, I use SimpleWebAuthn, a library that wraps the WebAuthn API calls, deals with the CBOR data and return them as string. I use the following two libraries from SimpleWebAuthn:

SimpleWebAuthn also provides a server side library for Node.js if you want to implement your backend with JavaScript/TypeScript.

Database

The demo application stores user and credential data in a PostgreSQL database. Here is the schema:

USERSintidPKvarcharusernametimestampregistration_starttimestampcreated_atCREDENTIALSbyteacred_idPKbyteacred_public_keyintuser_idFKbyteawebauthn_user_idintcountertimestampcreated_attimestamplast_usedSESSIONStexttokenPKbyteadatatimestampexpiryhasbelongs to

With this schema, a user can have one or more WebAuthn keys.

users:

Stores user information.

credentials:

Houses essential credential data, primarily the public key.

The sessions table stores session data and is used by the SCS library.

For database access, the Go application utilizes sqlboiler, and for the database migrations goose.

Implementation

To implement a WebAuthn solution, we must implement two primary workflows: registration and authentication. Each workflow consists of two requests, one to start the process and one to finish it. An implementation needs to store data in a server-side session between these two requests.

The go-webauthn library works with a User interface. Most methods of the library expect an implementation of this interface as an argument. You can find the demo application's implementation of this interface here. This abstraction makes the library independent of any framework and storage implementation.


First the applications needs to configure the WebAuthn library.

  wa, err := webauthn.New(&webauthn.Config{
    RPDisplayName: cfg.WebAuthn.RPDisplayName,
    RPID:          cfg.WebAuthn.RPID,
    RPOrigins:     []string{cfg.WebAuthn.RPOrigins},
  })

main.go

Registration

The registration workflow registers a new WebAuthn key for a user.

Relying PartyWeb BrowserAuthenticatorRelying PartyWeb BrowserAuthenticatorRequest challenge1Random challenge2Request to create new credentials3Generate new key pair & return public key4Public key and signed challenge5Verifies signature6Confirm registration if valid7

In this application, the registration process starts when the user clicks the Registration button and enters a username. As discussed earlier, the username is not strictly required for the server-side WebAuthn implementation. Still, it is helpful for the front end because the authenticator assigns the key pair and username to this website. This allows you to create multiple key pairs for the same website with different usernames. The authenticator generates a random username if the application does not ask for a username.

With the SimpleWebAuthn library, a registration implementation looks like this. Note that the library does not depend on Angular or Ionic. You can use the library with any front end framework.

The code first sends a POST request to the server with the username.

    this.httpClient.post<PublicKeyCredentialCreationOptionsJSON>(`${environment.API_URL}/registration/start`, userNameInput)
      .subscribe({
        next: async (response) => {
          await loading.dismiss();
          await this.handleSignUpStartResponse(response);
        },
        error: (errorResponse) => {
          loading.dismiss();
          const response: Errors = errorResponse.error;
          if (response?.errors) {
            displayFieldErrors(form, response.errors)
          }
          this.messagesService.showErrorToast('Registration failed');
        }
      });

registration.page.ts

The server takes this data and inserts a new user record into the users table. The registration_start field is set to the current timestamp to mark the beginning of the registration process.

const registrationSessionDataKey = "webAuthnRegistrationSessionData"
const registrationSessionUserId = "webAuthnRegistrationSessionUserId"

func (app *application) registrationStart(w http.ResponseWriter, r *http.Request) {
  tx := r.Context().Value(transactionKey).(*sql.Tx)

  var usernameInput dto.UsernameInput
  if ok := request.DecodeJSONValidate[*dto.UsernameInput](w, r, &usernameInput, dto.ValidateUsernameInput); !ok {
    return
  }

  user := models.User{
    Username: usernameInput.Username,
    RegistrationStart: null.Time{
      Time:  time.Now(),
      Valid: true,
    },
  }
  if err := user.Insert(r.Context(), tx, boil.Infer()); err != nil {
    response.InternalServerError(w, err)
    return
  }

registration.go

Next, the application generates a random ID for the user, which is associated with the user during the registration flow. The code then calls the BeginRegistration method of the WebAuthn library to start the registration process. The given arguments tell the library only to allow resident keys. With the configuration option UserVerification, we tell the authenticator to use an authentication method, if available, to access the private key. You can also set this to VerificationRequired to force the authenticator to always ask the user for a password, PIN, or biometric authentication to access the private key.

The BeginRegistration method returns two objects: options, which contains the random challenge the server has to return to the client, and the sessionData object, which the application stores in the session storage. This object is needed in the second request of the workflow to finish the registration.

  rnd := make([]byte, 64)
  if _, err := rand.Read(rnd); err != nil {
    response.InternalServerError(w, err)
    return
  }
  webAuthnUser := &WebAuthnUser{
    username: user.Username,
    id:       rnd,
  }

  requireResidentKey := true
  options, sessionData, err := app.webAuthn.BeginRegistration(webAuthnUser, webauthn.WithAuthenticatorSelection(protocol.AuthenticatorSelection{
    ResidentKey:        protocol.ResidentKeyRequirementRequired,
    RequireResidentKey: &requireResidentKey,
    UserVerification:   protocol.VerificationPreferred,
  }), webauthn.WithConveyancePreference(protocol.PreferNoAttestation),
    webauthn.WithExclusions([]protocol.CredentialDescriptor{}),
    webauthn.WithExtensions(protocol.AuthenticationExtensions{"credProps": true}),
  )

  if err != nil {
    response.InternalServerError(w, err)
    return
  }

  app.sessionManager.Put(r.Context(), registrationSessionDataKey, sessionData)
  app.sessionManager.Put(r.Context(), registrationSessionUserId, user.ID)

  response.JSON(w, http.StatusOK, options.Response)
}

registration.go

On the client side, the application receives the random challenge wrapped in a PublicKeyCredentialCreationOptionsJSON object. The application then sends this object to the startRegistration method, which interacts with the Web Authentication API to create a new private/public key pair. The method returns an object that contains the public key and the signed challenge (registrationResponse), which the code sends with a POST request to the server.

  private async handleSignUpStartResponse(creationOptions:  PublicKeyCredentialCreationOptionsJSON): Promise<void> {
    let registrationResponse: RegistrationResponseJSON | null = null;
    try {
      registrationResponse = await startRegistration(creationOptions)
    } catch (e) {
      await this.messagesService.showErrorToast('Registration failed with error ' + e);
      return;
    }
    const loading = await this.messagesService.showLoading('Finishing registration process...');
    await loading.present();

    this.httpClient.post(`${environment.API_URL}/registration/finish`, registrationResponse)
      .subscribe({
        next: () => {
          loading.dismiss();
          this.messagesService.showSuccessToast('Registration successful');
          this.router.navigate(['/login']);
        },
        error: () => {
          loading.dismiss();
          this.messagesService.showErrorToast('Registration failed');
        }
      });
  }

registration.page.ts

The server receives the public key and the signed challenge and starts the second part of the registration workflow. It retrieves the session data from the session storage and calls the WebAuthn library's FinishRegistration method. This method validates the signed challenge and the public key.

func (app *application) registrationFinish(w http.ResponseWriter, r *http.Request) {
  tx := r.Context().Value(transactionKey).(*sql.Tx)

  options, ok := app.sessionManager.Get(r.Context(), registrationSessionDataKey).(webauthn.SessionData)
  if !ok {
    err := fmt.Errorf("webAuthn session data not found")
    response.InternalServerError(w, err)
    return
  }
  userId, ok := app.sessionManager.Get(r.Context(), registrationSessionUserId).(int)
  if !ok {
    err := fmt.Errorf("webAuthn session user id not found")
    response.InternalServerError(w, err)
    return
  }

  user, err := models.FindUser(r.Context(), tx, userId)
  if err != nil {
    response.InternalServerError(w, err)
    return
  }
  webAuthnUser := &WebAuthnUser{
    username: user.Username,
    id:       options.UserID,
  }

  credential, err := app.webAuthn.FinishRegistration(webAuthnUser, options, r)
  if err != nil {
    response.InternalServerError(w, err)
    return
  }

registration.go

If the validation is successful, the application inserts a new record into the credentials table. The record contains the public key, the user ID, the random WebauthnUserID, the counter, and the last used timestamp.

After that, the application sets the registration_start field to null in the users table to mark the end of the registration process. The application then removes the session data from the session storage and returns a 200 status code to the client.

  appCredential := models.Credential{
    CredID:         credential.ID,
    CredPublicKey:  credential.PublicKey,
    UserID:         user.ID,
    WebauthnUserID: options.UserID,
    Counter:        int(credential.Authenticator.SignCount),
    LastUsed: null.Time{
      Time:  time.Now(),
      Valid: true,
    },
  }
  if err := appCredential.Insert(r.Context(), tx, boil.Infer()); err != nil {
    response.InternalServerError(w, err)
    return
  }

  err = models.Users(models.UserWhere.ID.EQ(user.ID)).
    UpdateAll(r.Context(), tx, models.M{models.UserColumns.RegistrationStart: null.Time{Valid: false}})
  if err != nil {
    response.InternalServerError(w, err)
    return
  }

  app.sessionManager.Remove(r.Context(), registrationSessionDataKey)
  app.sessionManager.Remove(r.Context(), registrationSessionUserId)
  w.WriteHeader(http.StatusOK)

registration.go

This concludes the registration workflow. The user can now log in with the WebAuthn key.

Cleanup unfinished registrations

The demo application has a cleanup task that runs every 20 minutes. The task deletes all user records with a registration_start timestamp older than 10 minutes. This is to clean up incomplete registrations that were started but not finished.

func (app *application) cleanup() {
  ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
  defer cancel()

  // Delete all users with a pending registration older than 10 minutes
  tenMinutesAgo := time.Now().Add(-10 * time.Minute)
  err := models.Users(models.UserWhere.RegistrationStart.LT(null.Time{
    Time:  tenMinutesAgo,
    Valid: true,
  })).DeleteAll(ctx, app.database)
  if err != nil {
    slog.Error("error deleting old pending sign ups", "error", err)
  }
}

cleanup.go

Another approach would be not storing a user record in the registrationStart method and inserting the user in the registrationFinish method when the registration is successful. This way, there is no need for a cleanup task.

The idea behind this demo application's approach is to have a way to check for the uniqueness of the username. If the username has already been taken, the application can return an error to the client before the registration process starts. The demo application does not implement this check but could be easily added with this architecture.

Authentication

The authentication workflow grants or denies a user access.

Relying PartyWeb BrowserAuthenticatorRelying PartyWeb BrowserAuthenticatorRequests challenge1Random challenge2Forwards challenge3Authenticator signs challenge4Signed challenge5Verifies signature6Grants access if valid7

The authentication process starts when the user clicks on the login button. Because this demo application only works with Resident Keys, the user does not have to enter any information. The application sends an empty POST request to the server to request a random challenge.

    this.httpClient.post<PublicKeyCredentialRequestOptionsJSON>(`${environment.API_URL}/authentication/start`, null)
      .subscribe({
        next: response => {
          loading.dismiss();
          this.handleLoginStartResponse(response);
        },
        error: () => {
          loading.dismiss();
          this.messagesService.showErrorToast('Login failed');
        }
      });

authentication.page.ts

The handler for this request generates a random challenge using the BeginDiscoverableLogin method of the WebAuthn library. Like in the registration workflow, the method returns two objects: options, which contains the random challenge, and the session data object, which the application stores in the session storage.

const authenticationSessionDataKey = "webAuthnAuthenticationSessionData"

func (app *application) authenticationStart(w http.ResponseWriter, r *http.Request) {
  options, sessionData, err := app.webAuthn.BeginDiscoverableLogin(webauthn.WithUserVerification(protocol.VerificationPreferred))
  if err != nil {
    response.InternalServerError(w, err)
    return
  }

  app.sessionManager.Put(r.Context(), authenticationSessionDataKey, sessionData)
  response.JSON(w, http.StatusOK, options.Response)
}

authentication.go

The application receives the random challenge on the client side and calls the startAuthentication method. This method sends the challenge to the Web Authentication API, which retrieves the stored private key. Based on the value of the UserVerification configuration, set during the registration workflow, the authenticator might ask the user to enter a PIN or password or use biometric authentication to access the private key. With the private key, the authenticator signs the challenge and returns the signed challenge to the application. The code sends the signed challenge to the server with a POST request.

  private async handleLoginStartResponse(response: PublicKeyCredentialRequestOptionsJSON): Promise<void> {
    let authenticationResponse: AuthenticationResponseJSON | null = null;
    try {
      authenticationResponse = await startAuthentication(response);
    } catch (e) {
      await this.messagesService.showErrorToast('Login failed with error ' + e);
      return;
    }
    const loading = await this.messagesService.showLoading('Validating ...');
    await loading.present();

    this.httpClient.post<void>(`${environment.API_URL}/authentication/finish`, authenticationResponse).subscribe({
      next: () => {
        loading.dismiss();
        this.navCtrl.navigateRoot('/home', {replaceUrl: true});
      },
      error: () => {
        loading.dismiss();
        this.messagesService.showErrorToast('Login failed');
      }
    });
  }

authentication.page.ts

The server receives the signed challenge, retrieves the stored session data from the first step of the workflow, Then, the FinishDiscoverableLogin method of the WebAuthn library is used to validate the signed challenge.

func (app *application) authenticationFinish(w http.ResponseWriter, r *http.Request) {
  tx := r.Context().Value(transactionKey).(*sql.Tx)
  sessionData, ok := app.sessionManager.Get(r.Context(), authenticationSessionDataKey).(webauthn.SessionData)
  if !ok {
    err := fmt.Errorf("webAuthn session data not found")
    response.InternalServerError(w, err)
    return
  }

  parsedResponse, err := protocol.ParseCredentialRequestResponseBody(r.Body)
  if err != nil {
    response.InternalServerError(w, err)
    return
  }

  credential, err := app.webAuthn.ValidateDiscoverableLogin(app.createDiscovarableUserHandler(r.Context(), tx), sessionData, parsedResponse)
  if err != nil {
    response.InternalServerError(w, err)
    return
  }

  if credential.Authenticator.CloneWarning {
    response.InternalServerError(w, fmt.Errorf("authenticator may be cloned"))
    return
  }

authentication.go

If the validation is successful, the application updates the counter and the last used timestamp in the credentials table. The application then removes the session data from the session storage and returns a 200 status code to the client. The application will return an error to the client if the validation fails.

  err = models.Credentials(
    models.CredentialWhere.WebauthnUserID.EQ(parsedResponse.Response.UserHandle),
    models.CredentialWhere.CredID.EQ(credential.ID),
  ).
    UpdateAll(r.Context(), tx,
      models.M{models.CredentialColumns.Counter: credential.Authenticator.SignCount,
        models.CredentialColumns.LastUsed: null.Time{
          Time:  time.Now(),
          Valid: true,
        }})
  if err != nil {
    response.InternalServerError(w, err)
    return
  }

  app.sessionManager.Remove(r.Context(), authenticationSessionDataKey)

  user, err := models.Credentials(models.CredentialWhere.CredID.EQ(credential.ID), qm.Select(models.CredentialColumns.UserID)).One(r.Context(), tx)
  if err != nil {
    response.InternalServerError(w, err)
    return
  }
  app.sessionManager.Put(r.Context(), "userID", user.UserID)
  w.WriteHeader(http.StatusOK)
}

func (app *application) createDiscovarableUserHandler(ctx context.Context, tx *sql.Tx) webauthn.DiscoverableUserHandler {
  return func(rawID, userHandle []byte) (webauthn.User, error) {
    credential, err := models.Credentials(models.CredentialWhere.WebauthnUserID.EQ(userHandle)).One(ctx, tx)
    if err != nil {
      return nil, err
    }
    return toWebAuthnUserWithCredentials(credential)
  }
}

authentication.go

This concludes the authentication workflow. The user is now logged in or has been denied access.


We reached the end of this blog post. We have seen how to implement a password-less and username-less authentication system with the Web Authentication API (WebAuthn) is in Go on the back, and Angular/Ionic is on the front end. Thanks to libraries like go-webauthn and SimpleWebAuthn the implementation is straightforward and can be done in a short amount of time. The libraries and the browser do the heavy lifting.