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.
-
Relying party
A website or online service to which a user wants to log in "relies" on another system to verify the user's identity. -
Authenticator
This represents the user's device for WebAuthn authentication. It could be a computer, a smartphone, or a hardware key like Yubikey. -
Resident Keys (Discoverable Credentials)
The private key is stored directly on the authenticator, and the relying party stores the public key. The authenticator can discover the private key directly without needing any additional information from the relying party. Resident keys provide a more user-friendly experience because they allow a password-less and username-less login experience. The Passkeys system only works with Resident keys. -
Non-Resident Keys (Non-Discoverable Credentials)
The authenticator does not store the private/public key pair; instead, it is stored by the relying party, and the authenticator holds a reference to these credentials. The user must typically provide a username to the relying party, which then uses that information to locate the stored credential.
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:
With this schema, a user can have one or more WebAuthn keys.
users: ¶
Stores user information.
- id: Unique identifier for each user.
- username: While not strictly required for WebAuthn itself (as authentication relies on public/private keys), it's beneficial for user management and communication within your application.
- registration_start: A timestamp used to track incomplete registrations, which is helpful for cleanup tasks.
- created_at: Timestamp for recording user creation time.
credentials: ¶
Houses essential credential data, primarily the public key.
- cred_id: Unique identifier for each credential.
- cred_public_key: The public key used to verify authentication assertions during login.
- user_id: Foreign key to the user table.
- webauthn_user_id: A unique identifier the server generates for each user, serving as an internal link within the WebAuthn system. The id is an opaque byte sequence with a maximum size of 64 bytes and is not meant to be displayed to the user. It's recommended that this value be completely random and use the entire 64 bytes (Spec).
- counter: A counter that increments with each successful authentication. This mechanism helps thwart replay attacks by ensuring that previous authentication attempts cannot be reused.
- created_at: Timestamp to record credential creation time.
- last_used: Timestamp to track when a credential was last successfully used for authentication.
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.
RPDisplayName
configures the display name for the Relying Party Server. This can be any string.RPID
configures the Relying Party Server ID. This should generally be the origin without a scheme and port.RPOrigins
configures the list of Relying Party Server Origins that are permitted. These should be fully qualified origins
wa, err := webauthn.New(&webauthn.Config{
RPDisplayName: cfg.WebAuthn.RPDisplayName,
RPID: cfg.WebAuthn.RPID,
RPOrigins: []string{cfg.WebAuthn.RPOrigins},
})
Registration ¶
The registration workflow registers a new WebAuthn key for a user.
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');
}
});
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
}
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)
}
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');
}
});
}
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
}
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)
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)
}
}
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.
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');
}
});
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)
}
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');
}
});
}
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
}
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)
}
}
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.