π What?
In this article, we are implementing full-working auth flow by WebAuthn / PassKey mechanism. We gonna use Go and vanilla JS with minimal amount of dependencies.
π€¦ββοΈ Why?
You can easily find a few pretty good articles about WebAuthn | PassKey. One of them has a lot of details and solution example on node.js. I recommend checking it out first. 
In this post, I'd like to tell about the implementation PassKey auth on a particular stack: Go + vanilla JS. We will make a backend of top of webauthn go lib, add a simple web page for registration | login, HTTP server to serve static and provide PassKey API.
A code tells us mach more, right?
TLDR; https://github.com/egregors/go-passkey
π§βπ» Implementation
So, in general we want to make a tiny website, which allow us to sign up new user with only username and PassKey and login with the same creds. 
For backend, I gonna use go (go1.21.7) with the only two dependencies:
- 
github.com/go-webauthn/webauthn β default and looks like the only lib for 
webauthnon go - github.com/google/uuid β UUID provider, we will need it to generate session keys. Totally optional
 
We're going to implement a web server to serve static (index.html, js, css) and API to provide backend part of auth process. 
The frontend will be just a simple HTML form with username input and Registration and Login buttons. Client side of webauthn will represents in just a few JavaScript functions. 
β What? But... but we need to use React even for absolutely not suitable problems...
β No, we don't.
It will also be a few dependencies:
- https://simplewebauthn.dev/docs/packages/browser β JS lib to use WebAuthn browser API
 - https://getbootstrap.com/docs/5.0 β just to save time
 
ποΈ Architecture
Registration
On a high level, the whole process does not seem very complicated.
First, we ask username and send it to backend. In BeginRegistration API endpoint, we are trying to get existing user or create a new one. Here we call webAuthn.BeginRegistration to get auth options and sessions data. Both contain Challenge which should be signed by authenticator. Create a new session, put it with a session data from webAuthn to the store. And send options back to client. 
On a client, we got an auth options and now ready to communicate with browser WebAuthn API. Here we are using JS lib and call SimpleWebAuthnBrowser.startRegistration (inside register function). The lib asks the authenticator to create a new Passkey. Authenticator generates new key pair and provides attestationResponse. Attestation contains credential information and signed Challenge. Now we need just forward this attestationResponse to backend, by calling FinishRegistration endpoint.
On a backend side we just get session key from headers, with session information we can get User. After that we should provide user, session and attestation result for validation into webAuthn. If signature is correct and all other validation are passed, we'll get Credential (public key and some meta) of current user. Save it to the store and clear the session. Well done!
Login
It may not be obvious, but the login process looks almost the same as the registration process. The only difference here is we don't ask authenticator to create a new keys pair before signing the challenge. Let's just to implement it and you're going to see it be yourself.
π Backend
Before we start making API, we need to provide required dependencies. To deal with webAuthn we need a data store and webAuthn compatible User models.
type PasskeyUser interface {  
   webauthn.User  
   AddCredential(*webauthn.Credential)  
   UpdateCredential(*webauthn.Credential)  
}  
type PasskeyStore interface {  
   GetUser(userName string) PasskeyUser  
   SaveUser(PasskeyUser)  
   GetSession(token string) webauthn.SessionData  
   SaveSession(token string, data webauthn.SessionData)  
   DeleteSession(token string)  
}
Implementation of these stuff is not a main topic here, so let's skip it. (You always can find the whole code on GitHub: https://github.com/egregors/go-passkey?tab=readme-ov-file)
We are starting from creating webauthn backend and setting up the server:
./main.go
package main  
import (  
   "encoding/json"  
   "fmt"   
   "log"   
   "net/http"   
   "os"  
   "github.com/go-webauthn/webauthn/webauthn"   
   "github.com/google/uuid"
)  
var (  
   webAuthn *webauthn.WebAuthn  
   err      error  
   datastore PasskeyStore  
   l         Logger  
)
func main() {  
   l = log.Default()  
   proto := getEnv("PROTOCOL", "http")  
   host := getEnv("HOST", "localhost")  
   port := getEnv("PORT", ":8080")  
   origin := fmt.Sprintf("%s://%s%s", proto, host, port)  
   l.Printf("[INFO] make webauthn config")  
   wconfig := &webauthn.Config{  
      RPDisplayName: "Go Webauthn",    // Display Name for your site  
      RPID:          host,             // Generally the FQDN for your site  
      RPOrigins:     []string{origin}, // The origin URLs allowed for WebAuthn  
   }  
   l.Printf("[INFO] create webauthn")  
   if webAuthn, err = webauthn.New(wconfig); err != nil {  
      fmt.Printf("[FATA] %s", err.Error())  
      os.Exit(1)  
   }  
   l.Printf("[INFO] create datastore")  
   datastore = NewInMem(l)  
   l.Printf("[INFO] register routes")  
   // Serve the web files  
   http.Handle("/", http.FileServer(http.Dir("./web")))  
   // Add auth the routes  
   http.HandleFunc("/api/passkey/registerStart", BeginRegistration)  
   http.HandleFunc("/api/passkey/registerFinish", FinishRegistration)  
   http.HandleFunc("/api/passkey/loginStart", BeginLogin)  
   http.HandleFunc("/api/passkey/loginFinish", FinishLogin)  
   // Start the server  
   l.Printf("[INFO] start server at %s", origin)  
   if err := http.ListenAndServe(port, nil); err != nil {  
      fmt.Println(err)  
   }  
}
// getEnv is a helper function to get the environment variable
func getEnv(key, def string) string {  
   if value, exists := os.LookupEnv(key); exists {  
      return value  
   }  
   return def  
}
As you see, here we create a webAuthn instance and add 5 http handlers:
- 
/β going to serve static files from./web: index.html, css and script.js - 
/api/passkey/registerStartβ get username from client, send auth options back - 
/api/passkey/registerFinishβ get new public key and attestation from client, sent validation result - 
/api/passkey/loginStartβ get username, send auth options back - 
/api/passkey/loginFinishβ get attestation from client, sent validation 
Each of Start | Finish hanlder look simular:
./main.go
func BeginRegistration(w http.ResponseWriter, r *http.Request) {  
   l.Printf("[INFO] begin registration ----------------------\\")  
   username, err := getUsername(r)  
   if err != nil {  
      l.Printf("[ERRO] can't get user name: %s", err.Error())  
      panic(err)  
   }  
   user := datastore.GetUser(username) // Find or create the new user  
   options, session, err := webAuthn.BeginRegistration(user)  
   if err != nil {  
      msg := fmt.Sprintf("can't begin registration: %s", err.Error())  
      l.Printf("[ERRO] %s", msg)  
      JSONResponse(w, "", msg, http.StatusBadRequest)  
      return  
   }  
   // Make a session key and store the sessionData values  
   t := uuid.New().String()  
   datastore.SaveSession(t, *session)  
   JSONResponse(w, t, options, http.StatusOK) // return the options generated with the session key  
   // options.publicKey contain our registration options
}  
func FinishRegistration(w http.ResponseWriter, r *http.Request) {  
   // Get the session key from the header  
   t := r.Header.Get("Session-Key")  
   // Get the session data stored from the function above  
   session := datastore.GetSession(t) // FIXME: cover invalid session  
   // In out example username == userID, but in real world it should be different   user := datastore.GetUser(string(session.UserID)) // Get the user  
   credential, err := webAuthn.FinishRegistration(user, session, r)  
   if err != nil {  
      msg := fmt.Sprintf("can't finish registration: %s", err.Error())  
      l.Printf("[ERRO] %s", msg)  
      JSONResponse(w, "", msg, http.StatusBadRequest)  
      return  
   }  
   // If creation was successful, store the credential object  
   user.AddCredential(credential)  
   datastore.SaveUser(user)  
   // Delete the session data  
   datastore.DeleteSession(t)  
   l.Printf("[INFO] finish registration ----------------------/")  
   JSONResponse(w, "", "Registration Success", http.StatusOK) // Handle next steps  
}  
func BeginLogin(w http.ResponseWriter, r *http.Request) {  
   l.Printf("[INFO] begin login ----------------------\\")  
   username, err := getUsername(r)  
   if err != nil {  
      l.Printf("[ERRO]can't get user name: %s", err.Error())  
      panic(err)  
   }  
   user := datastore.GetUser(username) // Find the user  
   options, session, err := webAuthn.BeginLogin(user)  
   if err != nil {  
      msg := fmt.Sprintf("can't begin login: %s", err.Error())  
      l.Printf("[ERRO] %s", msg)  
      JSONResponse(w, "", msg, http.StatusBadRequest)  
      return  
   }  
   // Make a session key and store the sessionData values  
   t := uuid.New().String()  
   datastore.SaveSession(t, *session)  
   JSONResponse(w, t, options, http.StatusOK) // return the options generated with the session key  
   // options.publicKey contain our registration options
}  
func FinishLogin(w http.ResponseWriter, r *http.Request) {  
   // Get the session key from the header  
   t := r.Header.Get("Session-Key")  
   // Get the session data stored from the function above  
   session := datastore.GetSession(t) // FIXME: cover invalid session  
   // In out example username == userID, but in real world it should be different   user := datastore.GetUser(string(session.UserID)) // Get the user  
   credential, err := webAuthn.FinishLogin(user, session, r)  
   if err != nil {  
      l.Printf("[ERRO] can't finish login %s", err.Error())  
      panic(err)  
   }  
   // Handle credential.Authenticator.CloneWarning  
   if credential.Authenticator.CloneWarning {  
      l.Printf("[WARN] can't finish login: %s", "CloneWarning")  
   }  
   // If login was successful, update the credential object  
   user.UpdateCredential(credential)  
   datastore.SaveUser(user)  
   // Delete the session data  
   datastore.DeleteSession(t)  
   l.Printf("[INFO] finish login ----------------------/")  
   JSONResponse(w, "", "Login Success", http.StatusOK)  
}  
// JSONResponse is a helper function to send json responsefunc JSONResponse(w http.ResponseWriter, sessionKey string, data interface{}, status int) {  
   w.Header().Set("Content-Type", "application/json")  
   w.Header().Set("Session-Key", sessionKey)  
   w.WriteHeader(status)  
   _ = json.NewEncoder(w).Encode(data)  
}  
// getUsername is a helper function to extract the username from json requestfunc getUsername(r *http.Request) (string, error) {  
   type Username struct {  
      Username string `json:"username"`  
   }  
   var u Username  
   if err := json.NewDecoder(r.Body).Decode(&u); err != nil {  
      return "", err  
   }  
   return u.Username, nil  
}
And... in fact, all the work on the server side that needed to be done.
π¨βπ¨ Frontend
For frontend part we need first of all HTML page
./web/index.html
<!DOCTYPE html>  
<html lang="en">  
<head>  
    <meta charset="UTF-8">  
    <meta name="viewport" content="width=device-width, initial-scale=1.0">  
    <title>Passkey</title>  
    <link href="bootstrap.min.css" rel="stylesheet">  
</head>  
<body>  
<div class="container d-flex justify-content-center align-items-center vh-100">  
    <div class="bg-light p-5 rounded w-50">  
        <h1 class="mb-4 text-center">π Passkey</h1>  
        <div class="text-center" id="message"></div>  
        <div class="mb-3">  
            <input type="text" class="form-control" id="username" placeholder="username">  
        </div>        
        <div class="d-grid gap-2">  
            <div class="row">  
                <div class="col">  
                    <button class="btn btn-primary w-100" id="registerButton">Register</button>  
                </div>                
                <div class="col">  
                    <button class="btn btn-primary w-100" id="loginButton">Login</button>  
                </div>            
            </div>        
        </div>    
    </div>
</div>  
<script src="index.es5.umd.min.js"></script>  
<script src="script.js"></script>  
</body>  
</html>
Here we just make a simple "form" with username input, two buttons and place to messages. I use bootstrap ccs here to make the form a bit prettier and index.es5.umd.min.js β js lib for working with browser API. We do not use here any other frameworks. Just vanilla JS and SimpleWebAuthnBrowser lib.
All the magic will be inside script.js. Just kidding, there is no magic here at all.
./web/script.js
document.getElementById('registerButton').addEventListener('click', register);  
document.getElementById('loginButton').addEventListener('click', login);  
function showMessage(message, isError = false) {  
    const messageElement = document.getElementById('message');  
    messageElement.textContent = message;  
    messageElement.style.color = isError ? 'red' : 'green';  
}  
async function register() {  
    // Retrieve the username from the input field  
    const username = document.getElementById('username').value;  
    try {  
        // Get registration options from your server. Here, we also receive the challenge.  
        const response = await fetch('/api/passkey/registerStart', {  
            method: 'POST', headers: {'Content-Type': 'application/json'},  
            body: JSON.stringify({username: username})  
        });  
        console.log(response)  
        // Check if the registration options are ok.  
        if (!response.ok) {  
            const msg = await response.json();  
            throw new Error('User already exists or failed to get registration options from server: ' + msg);  
        }  
        // Convert the registration options to JSON.  
        const options = await response.json();  
        console.log(options)  
        // This triggers the browser to display the passkey / WebAuthn modal (e.g. Face ID, Touch ID, Windows Hello).  
        // A new attestation is created. This also means a new public-private-key pair is created.        
        const attestationResponse = await SimpleWebAuthnBrowser.startRegistration(options.publicKey);  
        // Send attestationResponse back to server for verification and storage.  
        const verificationResponse = await fetch('/api/passkey/registerFinish', {  
            method: 'POST',  
            headers: {  
                'Content-Type': 'application/json',  
                'Session-Key': response.headers.get('Session-Key')  
            },  
            body: JSON.stringify(attestationResponse)  
        });  
        const msg = await verificationResponse.json();  
        if (verificationResponse.ok) {  
            showMessage(msg, false);  
        } else {  
            showMessage(msg, true);  
        }  
    } catch  
        (error) {  
        showMessage('Error: ' + error.message, true);  
    }  
}  
async function login() {  
    // Retrieve the username from the input field  
    const username = document.getElementById('username').value;  
    try {  
        // Get login options from your server. Here, we also receive the challenge.  
        const response = await fetch('/api/passkey/loginStart', {  
            method: 'POST', headers: {'Content-Type': 'application/json'},  
            body: JSON.stringify({username: username})  
        });  
        // Check if the login options are ok.  
        if (!response.ok) {  
            const msg = await response.json();  
            throw new Error('Failed to get login options from server: ' + msg);  
        }  
        // Convert the login options to JSON.  
        const options = await response.json();  
        console.log(options)  
        // This triggers the browser to display the passkey / WebAuthn modal (e.g. Face ID, Touch ID, Windows Hello).  
        // A new assertionResponse is created. This also means that the challenge has been signed.        
        const assertionResponse = await SimpleWebAuthnBrowser.startAuthentication(options.publicKey);  
        // Send assertionResponse back to server for verification.  
        const verificationResponse = await fetch('/api/passkey/loginFinish', {  
            method: 'POST',  
            headers: {  
                'Content-Type': 'application/json',  
                'Session-Key': response.headers.get('Session-Key'),  
            },  
            body: JSON.stringify(assertionResponse)  
        });  
        const msg = await verificationResponse.json();  
        if (verificationResponse.ok) {  
            showMessage(msg, false);  
        } else {  
            showMessage(msg, true);  
        }  
    } catch (error) {  
        showMessage('Error: ' + error.message, true);  
    }  
}
π€ What next?
So, now we know how to register and login users by passKey, but is it a complete solution? Of course not!
- Session processing are simplified to maintain low complicity level (session key could be easily stolen)
 - Store is just inmem struct with a few maps (thread unsafe!)
 - We didn't provide any middleware to actually use authentication (some public and some private routes)
 
Besides, we left here a pair of fascinating question:
- How to allow user to have several passkeys associated with one account?
 - What should we do if 
credential.Authenticator.CloneWarningis true? - It would be nice to have some sort of middleware to handle User status.
 
π Reference
- https://github.com/egregors/go-passkey?tab=readme-ov-file
 - https://github.com/go-webauthn/webauthn
 - https://simplewebauthn.dev/docs/packages/browser
 - https://www.corbado.com/blog/passkey-tutorial-how-to-implement-passkeys
 - https://teampassword.com/blog/passkey-vs-webauthn
 - https://github.com/herrjemand/awesome-webauthn?tab=readme-ov-file
 
              
    
Top comments (2)
Nice tutorial!
very helpful