π 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
webauthn
on 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.CloneWarning
is 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