Home | Send Feedback | Share on Bluesky |

WebAuthn with Go

Published: 27. September 2024  •  Updated: 20. March 2026  •  go, angular

In this blog post, I will show you how to implement a passwordless authentication system with the Web Authentication API (WebAuthn) in Go on the back end 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 uses 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 lets users store keys in the cloud and replicate them across 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).

Cross-platform passkey portability is still evolving. Synchronization generally works well within a vendor ecosystem or through a password manager. I use Bitwarden, which synchronizes passwords and WebAuthn keys across different operating systems.

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 third-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.

On the front end, the browser already provides the WebAuthn API. In this demo, I use SimpleWebAuthn for registration because it wraps the WebAuthn API calls, deals with the CBOR data, and returns JSON-friendly values. Authentication uses the native browser API so the application can take advantage of conditional mediation and passkey autofill.

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:

has

belongs to

USERS

int

id

PK

varchar

username

timestamp

registration_start

timestamp

created_at

CREDENTIALS

bytea

cred_id

PK

int

user_id

FK

bytea

webauthn_user_id

timestamp

created_at

timestamp

last_used

bytea

aaguid

varchar

attestation_type

varchar

attachment

varchar

transport

int

sign_count

boolean

clone_warning

boolean

present

boolean

verified

boolean

backup_eligible

boolean

backup_state

bytea

public_key

SESSIONS

text

token

PK

bytea

data

timestamp

expiry

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 uses sqlboiler, and for the database migrations goose.

Check out this discussion about the database design for a WebAuthn implementation.

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 application 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.

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(optionsJSON: PublicKeyCredentialCreationOptionsJSON): Promise<void> {
    let registrationResponse: RegistrationResponseJSON;
    try {
      registrationResponse = await startRegistration({optionsJSON});
    } 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.

  var transports strings.Builder
  for i, t := range credential.Transport {
    if i > 0 {
      transports.WriteString(",")
    }
    transports.WriteString(string(t))
  }

  appCredential := models.Credential{
    CredID:         credential.ID,
    UserID:         user.ID,
    WebauthnUserID: options.UserID,
    LastUsed: null.Time{
      Time:  time.Now(),
      Valid: true,
    },
    Aaguid: null.Bytes{
      Bytes: credential.Authenticator.AAGUID,
      Valid: len(credential.Authenticator.AAGUID) > 0,
    },
    AttestationType: null.String{
      String: credential.AttestationType,
      Valid:  credential.AttestationType != "",
    },
    Attachment:     string(credential.Authenticator.Attachment),
    Transport:      transports.String(),
    SignCount:      int(credential.Authenticator.SignCount),
    CloneWarning:   credential.Authenticator.CloneWarning,
    Present:        credential.Flags.UserPresent,
    Verified:       credential.Flags.UserVerified,
    BackupEligible: credential.Flags.BackupEligible,
    BackupState:    credential.Flags.BackupState,
    PublicKey:      credential.PublicKey,
  }
  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 page starts a passkey autofill request as soon as it loads if the browser supports conditional mediation. The username field is only there to trigger browser autofill and is not submitted to the server. The Sign in with passkey button remains as a fallback for browsers that do not support conditional mediation or for users who prefer the explicit flow. In both cases, the application sends an empty POST request to the server to request a random challenge.

  ngOnInit(): void {
    void this.startPasskeyAutofill();
  }

  ngOnDestroy(): void {
    this.abortConditionalMediation();
  }

  async login(): Promise<void> {
    this.abortConditionalMediation();

    const loading = await this.#messagesService.showLoading('Starting login ...');
    try {
      const response = await firstValueFrom(
        this.#httpClient.post<PublicKeyCredentialRequestOptionsJSON>(`${environment.API_URL}/authentication/start`, null)
      );
      await loading.dismiss();
      await this.handleLoginStartResponse(response);
    }
    catch {
      await loading.dismiss();
      await this.#messagesService.showErrorToast('Login failed');
    }
  }

  private async handleLoginStartResponse(optionsJSON: PublicKeyCredentialRequestOptionsJSON): Promise<void> {
    try {
      const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJSON);
      const credential = await navigator.credentials.get({publicKey}) as PublicKeyCredential | null;

      if (!credential) {
        return;
      }

      await this.finishAuthentication(credential.toJSON());
    }
    catch (error) {
      if (!this.isExpectedCredentialError(error)) {
        await this.#messagesService.showErrorToast('Login failed');
      }
    }
  }

  private async startPasskeyAutofill(): Promise<void> {
    if (!window.PublicKeyCredential
      || typeof PublicKeyCredential.isConditionalMediationAvailable !== 'function') {
      return;
    }

    try {
      this.conditionalMediationAvailable =
        await PublicKeyCredential.isConditionalMediationAvailable();

      if (!this.conditionalMediationAvailable) {
        return;
      }

      const response = await firstValueFrom(
        this.#httpClient.post<PublicKeyCredentialRequestOptionsJSON>(`${environment.API_URL}/authentication/start`, null)
      );
      const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(response);

      this.#conditionalMediationAbortController = new AbortController();
      const credential = await navigator.credentials.get({
        publicKey,
        mediation: 'conditional',
        signal: this.#conditionalMediationAbortController.signal
      }) as PublicKeyCredential | null;
      this.#conditionalMediationAbortController = null;

      if (!credential) {
        return;
      }

      await this.finishAuthentication(credential.toJSON());
    }
    catch (error) {
      this.#conditionalMediationAbortController = null;
      if (!this.isExpectedCredentialError(error)) {
        await this.#messagesService.showErrorToast('Passkey autofill 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.


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 converts the JSON payload into native WebAuthn request options with PublicKeyCredential.parseRequestOptionsFromJSON. For the manual flow, it calls navigator.credentials.get({ publicKey }). For passkey autofill, it calls navigator.credentials.get with mediation: 'conditional' and an AbortController so the request can be canceled cleanly if the user switches to the manual login flow. 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. The authenticator signs the challenge and returns the assertion, which the code sends to the server as JSON.

  private async handleLoginStartResponse(optionsJSON: PublicKeyCredentialRequestOptionsJSON): Promise<void> {
    try {
      const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJSON);
      const credential = await navigator.credentials.get({publicKey}) as PublicKeyCredential | null;

      if (!credential) {
        return;
      }

      await this.finishAuthentication(credential.toJSON());
    }
    catch (error) {
      if (!this.isExpectedCredentialError(error)) {
        await this.#messagesService.showErrorToast('Login failed');
      }
    }
  }

  private async startPasskeyAutofill(): Promise<void> {
    if (!window.PublicKeyCredential
      || typeof PublicKeyCredential.isConditionalMediationAvailable !== 'function') {
      return;
    }

    try {
      this.conditionalMediationAvailable =
        await PublicKeyCredential.isConditionalMediationAvailable();

      if (!this.conditionalMediationAvailable) {
        return;
      }

      const response = await firstValueFrom(
        this.#httpClient.post<PublicKeyCredentialRequestOptionsJSON>(`${environment.API_URL}/authentication/start`, null)
      );
      const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(response);

      this.#conditionalMediationAbortController = new AbortController();
      const credential = await navigator.credentials.get({
        publicKey,
        mediation: 'conditional',
        signal: this.#conditionalMediationAbortController.signal
      }) as PublicKeyCredential | null;
      this.#conditionalMediationAbortController = null;

      if (!credential) {
        return;
      }

      await this.finishAuthentication(credential.toJSON());
    }
    catch (error) {
      this.#conditionalMediationAbortController = null;
      if (!this.isExpectedCredentialError(error)) {
        await this.#messagesService.showErrorToast('Passkey autofill failed');
      }
    }
  }

  private async finishAuthentication(credential: PublicKeyCredentialJSON): Promise<void> {
    const loading = await this.#messagesService.showLoading('Validating ...');

    try {
      await firstValueFrom(this.#httpClient.post<void>(`${environment.API_URL}/authentication/finish`, credential));
      await loading.dismiss();
      await this.#navCtrl.navigateRoot('/home', {replaceUrl: true});
    }
    catch {
      await loading.dismiss();
      await this.#messagesService.showErrorToast('Login failed');
    }
  }

  private abortConditionalMediation(): void {
    this.#conditionalMediationAbortController?.abort();
    this.#conditionalMediationAbortController = null;
  }

  private isExpectedCredentialError(error: unknown): boolean {
    return error instanceof DOMException
      && (error.name === 'AbortError' || error.name === 'NotAllowedError');
  }

authentication.page.ts

The server receives the signed challenge, retrieves the stored session data from the first step of the workflow, and uses the WebAuthn library to validate the assertion.


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.createDiscoverableUserHandler(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.


  cols := models.M{
    models.CredentialColumns.SignCount:    credential.Authenticator.SignCount,
    models.CredentialColumns.CloneWarning: credential.Authenticator.CloneWarning,
    models.CredentialColumns.LastUsed: null.Time{
      Time:  time.Now(),
      Valid: true,
    },
  }
  if credential.Flags.BackupEligible {
    cols[models.CredentialColumns.BackupState] = credential.Flags.BackupState
  }

  err = models.Credentials(
    models.CredentialWhere.WebauthnUserID.EQ(parsedResponse.Response.UserHandle),
    models.CredentialWhere.CredID.EQ(credential.ID),
  ).
    UpdateAll(r.Context(), tx, cols)
  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)

authentication.go

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


We have reached the end of this blog post. We have seen how to implement a passwordless and username-less authentication system with the Web Authentication API (WebAuthn) in Go on the back end and Angular/Ionic 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.