Build Passkey Authentication in Go: A Complete Workshop
Passkeys let people sign in without a password. But a passkey is only the login proof. After login, most web applications still use a session cookie.
This workshop builds that complete path. A user will enter an email, create a passkey, sign in with it, receive a server-side session, and update a protected profile. We will also omit the CSRF token from one request and watch the server reject it.
We will build the application in three working checkpoints. Do not copy the final application at the start. At each checkpoint, every file either appears in full or is changed by an exact patch.
- Register a passkey. Run it and prove registration works.
- Add passkey login and a server-side session. Run it again.
- Add a state-changing profile request, see why its session cookie is not enough, then add and test CSRF protection.
A few words before we start
| Word | Plain meaning |
|---|---|
| Passkey | A WebAuthn credential backed by a public/private key pair. |
| Authenticator | The software or hardware that creates and uses the credential. Examples include Windows Hello, a phone, a security key, or a passkey provider. |
| Relying party (RP) | The website that accepts the credential. Here, it is our Go application. |
| Challenge | Random data created for one ceremony and accepted once. It prevents replay. |
| Ceremony | One complete registration or login exchange. |
| Credential | The server’s record of a passkey: its ID, public key, and metadata. |
| Session | A server-side record that says a browser has signed in. |
| CSRF token | A secret bound to the session that another site cannot normally read or add to a request. |
The private key is never sent to our server. A synced passkey provider may move encrypted credential material between a user’s devices, but the relying party receives only public credential data.
What we will build
| Endpoint | Purpose |
|---|---|
POST /api/passkeys/registration/begin | Create registration options and a challenge |
POST /api/passkeys/registration/finish | Verify and store the public-key credential |
POST /api/passkeys/login/begin | Create authentication options and a challenge |
POST /api/passkeys/login/finish | Verify the signed login response and create a session |
GET /api/session | Read the current user and CSRF token |
POST /api/profile | Update a profile using the session and CSRF token |
POST /api/logout | Revoke the session using the CSRF token |
sequenceDiagram
participant Browser
participant Go as Go server
participant Authenticator
Browser->>Go: Begin registration with email
Go-->>Browser: Challenge and creation options
Browser->>Authenticator: Create a credential
Authenticator-->>Browser: Attestation response
Browser->>Go: Finish registration
Go->>Go: Verify and store credential record
Browser->>Go: Begin login with email
Go-->>Browser: Fresh challenge
Browser->>Authenticator: Sign ceremony data
Authenticator-->>Browser: Signed login response
Browser->>Go: Finish login
Go-->>Browser: Session cookie
The CSRF token is deliberately absent from this diagram. Authentication comes first. We will introduce CSRF only after the application has a cookie-authenticated, state-changing request.
The demo stores everything in memory. Restarting it erases users, credentials, ceremonies, and sessions.
It also treats the submitted email as already verified. That is acceptable only for this local workshop. A production service must verify mailbox ownership before creating the first passkey. Otherwise someone could claim another person’s address.
Requirements
You need:
- Go 1.26 or newer;
- a current browser with WebAuthn support;
localhost, which browsers treat as a secure development context.
Production WebAuthn uses HTTPS. Production cookies also need the Secure flag. This demo uses HTTP and non-Secure cookies only on localhost.
Step 1: Create the module
Start in an empty directory:
mkdir passkeyauth
cd passkeyauth
go mod init passkeyauth
go get github.com/go-webauthn/[email protected]
mkdir -p cmd/passkeyauth internal/auth static
touch cmd/passkeyauth/main.go \
internal/auth/app.go \
internal/auth/store.go \
internal/auth/helpers.go \
internal/auth/ceremony.go \
internal/auth/registration.go \
static/index.html static/app.js
go-webauthn parses authenticator data and verifies WebAuthn ceremonies. We pin version v0.18.1 because the module is still pre-1.0.
Your go.mod will contain the direct dependency below. go mod tidy will add its indirect dependencies later. Go generates go.sum; do not write that file by hand.
module passkeyauth
go 1.26.0
require github.com/go-webauthn/webauthn v0.18.1
require (
github.com/fxamacker/cbor/v2 v2.9.3 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.3.1 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/crypto v0.57.0 // indirect
golang.org/x/sys v0.48.0 // indirect
)
The project will end with this shape:
passkeyauth/
├── go.mod
├── go.sum
├── cmd/passkeyauth/main.go
├── internal/auth/
│ ├── app.go
│ ├── app_test.go
│ ├── ceremony.go
│ ├── helpers.go
│ ├── login.go
│ ├── middleware.go
│ ├── registration.go
│ ├── session.go
│ └── store.go
└── static/
├── app.test.mjs
├── app.js
└── index.html
Checkpoint 1: register a passkey
The first application does one job: registration. It has no login route, session type, profile, logout, or CSRF token.
Step 2: Create the registration data model
Create internal/auth/store.go with this first version:
The User type implements the four methods required by webauthn.User. WebAuthnID returns a random, stable user handle. We do not use the email because an email can change and contains identifying information. The server treats this handle as a random identifier with no meaning. It is not a password or secret.
Credentials contains the records returned after successful registration. Each record has a credential ID, public key, authenticator state, flags, and metadata. It never contains the private key.
Ceremony exists only between a WebAuthn begin request and finish request. It is temporary WebAuthn state, not a login session.
The store returns user copies so handlers do not read a mutable user while another request updates it. It also checks credential IDs across every account before saving a first credential.
package auth
import (
"bytes"
"errors"
"sync"
"time"
"github.com/go-webauthn/webauthn/webauthn"
)
type User struct {
ID []byte
Email string
Credentials []webauthn.Credential
}
func (u *User) WebAuthnID() []byte { return u.ID }
func (u *User) WebAuthnName() string { return u.Email }
func (u *User) WebAuthnDisplayName() string { return u.Email }
func (u *User) WebAuthnCredentials() []webauthn.Credential { return u.Credentials }
type Ceremony struct {
Email string
Kind string
Data webauthn.SessionData
ExpiresAt time.Time
}
type Store struct {
mu sync.RWMutex
users map[string]*User
ceremonies map[string]Ceremony
}
func newStore() *Store {
return &Store{
users: make(map[string]*User),
ceremonies: make(map[string]Ceremony),
}
}
func cloneUser(user *User) *User {
if user == nil {
return nil
}
copy := *user
copy.ID = bytes.Clone(user.ID)
copy.Credentials = append([]webauthn.Credential(nil), user.Credentials...)
return ©
}
func (s *Store) userByEmail(email string) *User {
s.mu.RLock()
defer s.mu.RUnlock()
return cloneUser(s.users[email])
}
func (s *Store) userForRegistration(email string) (*User, error) {
s.mu.Lock()
defer s.mu.Unlock()
user := s.users[email]
if user == nil {
user = &User{ID: randomBytes(32), Email: email}
s.users[email] = user
}
if len(user.Credentials) > 0 {
return nil, errors.New("this workshop account already has a passkey")
}
return cloneUser(user), nil
}
func (s *Store) addFirstCredential(email string, credential webauthn.Credential) error {
s.mu.Lock()
defer s.mu.Unlock()
user := s.users[email]
if user == nil {
return errors.New("registration expired")
}
if len(user.Credentials) > 0 {
return errors.New("this workshop account already has a passkey")
}
for _, otherUser := range s.users {
for _, existing := range otherUser.Credentials {
if bytes.Equal(existing.ID, credential.ID) {
return errors.New("credential is already registered")
}
}
}
user.Credentials = append(user.Credentials, credential)
return nil
}
cloneUser creates a snapshot while the lock is held. A handler can safely use that snapshot after the store unlocks. Without the copy, another request could mutate the credential slice while WebAuthn reads it.
Step 3: Add the HTTP helpers
Create internal/auth/helpers.go.
This file reads the email, checks the JSON media type, generates random bytes with crypto/rand, and writes JSON responses. It also builds the credential summary that the workshop prints after registration and login. The email validation is intentionally light. Production signup normally needs a stronger email policy plus mailbox verification.
The exact media-type check matters. A prefix check would accidentally accept values such as application/jsonp.
package auth
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"mime"
"net/http"
"strings"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
)
type credentialSummary struct {
IDBase64URL string `json:"idBase64URL"`
PublicKeyBase64URL string `json:"publicKeyBase64URL"`
AttestationType string `json:"attestationType"`
AttestationFormat string `json:"attestationFormat"`
Transports []protocol.AuthenticatorTransport `json:"transports"`
Flags webauthn.CredentialFlags `json:"flags"`
Authenticator credentialAuthenticatorSummary `json:"authenticator"`
}
type credentialAuthenticatorSummary struct {
AAGUIDBase64URL string `json:"aaguidBase64URL"`
Attachment protocol.AuthenticatorAttachment `json:"attachment"`
SignCount uint32 `json:"signCount"`
CloneWarning bool `json:"cloneWarning"`
}
func summarizeCredential(credential *webauthn.Credential) credentialSummary {
return credentialSummary{
IDBase64URL: base64.RawURLEncoding.EncodeToString(credential.ID),
PublicKeyBase64URL: base64.RawURLEncoding.EncodeToString(credential.PublicKey),
AttestationType: credential.AttestationType,
AttestationFormat: credential.AttestationFormat,
Transports: credential.Transport,
Flags: credential.Flags,
Authenticator: credentialAuthenticatorSummary{
AAGUIDBase64URL: base64.RawURLEncoding.EncodeToString(credential.Authenticator.AAGUID),
Attachment: credential.Authenticator.Attachment,
SignCount: credential.Authenticator.SignCount,
CloneWarning: credential.Authenticator.CloneWarning,
},
}
}
func readEmail(r *http.Request) (string, error) {
var body struct {
Email string `json:"email"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
return "", errors.New("invalid JSON")
}
email := strings.ToLower(strings.TrimSpace(body.Email))
if email == "" || len(email) > 254 || !strings.Contains(email, "@") {
return "", errors.New("enter a valid email address")
}
return email, nil
}
func isJSON(r *http.Request) bool {
mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
return err == nil && strings.EqualFold(mediaType, "application/json")
}
func randomBytes(size int) []byte {
b := make([]byte, size)
if _, err := rand.Read(b); err != nil {
panic(fmt.Errorf("crypto/rand failed: %w", err))
}
return b
}
func randomToken(size int) string {
return base64.RawURLEncoding.EncodeToString(randomBytes(size))
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
Step 4: Configure registration routes
Create internal/auth/app.go. This version exposes only registration and static files.
These settings tell the browser and library which website may use the passkey:
RPIDis the domain scope of the credential. It has no scheme or port.RPOriginscontains the allowed web origins, including scheme and port.ResidentKeyRequirementRequiredrequests a discoverable credential, commonly called a passkey.VerificationRequiredrequests local user verification, such as a device PIN or fingerprint. Biometric data is not sent to our server.PreferNoAttestationavoids requesting identifying authenticator information. We still receive the credential data needed for authentication.
The routes use Go’s method-aware ServeMux patterns.
package auth
import (
"net/http"
"time"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
)
const (
ceremonyCookie = "passkey_ceremony"
ceremonyTTL = 5 * time.Minute
)
type Config struct {
RPDisplayName string
RPID string
RPOrigins []string
StaticDir string
}
type App struct {
webAuthn *webauthn.WebAuthn
store *Store
staticDir string
}
func New(cfg Config) (*App, error) {
web, err := webauthn.New(&webauthn.Config{
RPDisplayName: cfg.RPDisplayName,
RPID: cfg.RPID,
RPOrigins: cfg.RPOrigins,
AttestationPreference: protocol.PreferNoAttestation,
AuthenticatorSelection: protocol.AuthenticatorSelection{
ResidentKey: protocol.ResidentKeyRequirementRequired,
UserVerification: protocol.VerificationRequired,
},
})
if err != nil {
return nil, err
}
if cfg.StaticDir == "" {
cfg.StaticDir = "static"
}
return &App{webAuthn: web, store: newStore(), staticDir: cfg.StaticDir}, nil
}
func (a *App) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("POST /api/passkeys/registration/begin", a.beginRegistration)
mux.HandleFunc("POST /api/passkeys/registration/finish", a.finishRegistration)
mux.Handle("/", http.FileServer(http.Dir(a.staticDir)))
return mux
}
Step 5: Store one-use WebAuthn ceremonies
Create internal/auth/ceremony.go.
Registration and login each have a begin request and a finish request. The server must remember what it sent between those requests. webauthn.SessionData contains the challenge, RP ID, user ID, allowed credential IDs, user-verification policy, and other verification inputs.
We store that complete value on the server. The browser receives only a random lookup ID in an HttpOnly cookie. The cookie is also SameSite=Strict and limited to /api/passkeys/.
takeCeremony deletes the record before verification. The response cannot be replayed after either success or failure. It also checks the ceremony kind and our five-minute expiry.
This demo keeps abandoned ceremonies until restart. A production store needs periodic expiry cleanup.
package auth
import (
"errors"
"net/http"
"time"
"github.com/go-webauthn/webauthn/webauthn"
)
func (a *App) saveCeremony(w http.ResponseWriter, email, kind string, data webauthn.SessionData) {
id := randomToken(32)
a.store.mu.Lock()
a.store.ceremonies[id] = Ceremony{Email: email, Kind: kind, Data: data, ExpiresAt: time.Now().Add(ceremonyTTL)}
a.store.mu.Unlock()
http.SetCookie(w, &http.Cookie{
Name: ceremonyCookie,
Value: id,
Path: "/api/passkeys/",
HttpOnly: true,
Secure: false,
SameSite: http.SameSiteStrictMode,
MaxAge: int(ceremonyTTL.Seconds()),
})
}
func (a *App) takeCeremony(r *http.Request, kind string) (Ceremony, error) {
cookie, err := r.Cookie(ceremonyCookie)
if err != nil {
return Ceremony{}, errors.New("ceremony cookie is missing")
}
a.store.mu.Lock()
ceremony, ok := a.store.ceremonies[cookie.Value]
delete(a.store.ceremonies, cookie.Value)
a.store.mu.Unlock()
if !ok || ceremony.Kind != kind || time.Now().After(ceremony.ExpiresAt) {
return Ceremony{}, errors.New("ceremony is missing, expired, or already used")
}
return ceremony, nil
}
Step 6: Register the passkey
Create internal/auth/registration.go after reading this section.
Registration needs two HTTP requests. The server must create a fresh challenge before the authenticator creates a credential. The browser therefore talks to the server, then the authenticator, then the server again.
Begin registration
beginRegistration performs these steps:
- Require
application/json. - Normalize and validate the email.
- Load or create a workshop user with a random 32-byte handle.
- Refuse a second credential because this workshop supports one passkey per account.
- Call
BeginRegistration. - Store the returned ceremony data.
- Return the public creation options.
BeginRegistration returns two different values. options goes to the browser. data stays on the server.
The options contain the challenge, relying-party information, user information, accepted algorithms, timeout, and authenticator preferences. The exclusion list identifies credentials already registered for this account. The browser and authenticator use it to avoid creating a duplicate credential. The database still needs its own uniqueness check.
What happens inside registration
The browser passes the options to navigator.credentials.create(). Then:
- The browser checks that the RP ID is valid for the page’s origin.
- The authenticator asks for user consent and local user verification.
- The authenticator creates a new public/private key pair.
- The authenticator or passkey provider keeps the private key. It is not sent to our server.
- The authenticator creates authenticator data containing an RP ID hash, flags, and the new credential data.
- The browser returns an attestation object and client data.
The client data contains the ceremony type, challenge, and origin. The attestation object contains authenticator data and may contain an attestation statement, depending on policy.
Local user verification means the authenticator was unlocked according to its policy. It does not establish the person’s legal identity or prove ownership of the submitted email address.
Finish registration
finishRegistration removes the saved ceremony and calls FinishRegistration. The library checks the ceremony type, challenge, origin, RP ID hash, authenticator data, user-presence and user-verification flags, public-key algorithm, and attestation policy.
Only then does addFirstCredential save the credential. Never store a public key merely because a browser sent one.
For this workshop, the JSON response includes a summary of the stored credential. It shows the credential ID, public key, attestation details, transports, verification and backup flags, signature counter, and clone warning. Binary values use Base64URL so JSON can carry them.
The credential ID and public key are not secrets. The private key never reaches the server. Even so, a production response should normally contain only the data the page needs, such as { "message": "passkey registered" }. Returning the full summary exposes implementation details and may make it easier to identify a specific authenticator or credential.
package auth
import (
"net/http"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
)
func (a *App) beginRegistration(w http.ResponseWriter, r *http.Request) {
if !isJSON(r) {
writeError(w, http.StatusUnsupportedMediaType, "use application/json")
return
}
email, err := readEmail(r)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
user, err := a.store.userForRegistration(email)
if err != nil {
writeError(w, http.StatusConflict, err.Error())
return
}
options, data, err := a.webAuthn.BeginRegistration(
user,
webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired),
webauthn.WithExclusions(webauthn.Credentials(user.Credentials).CredentialDescriptors()),
)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not start registration")
return
}
a.saveCeremony(w, email, "registration", *data)
writeJSON(w, http.StatusOK, options)
}
func (a *App) finishRegistration(w http.ResponseWriter, r *http.Request) {
if !isJSON(r) {
writeError(w, http.StatusUnsupportedMediaType, "use application/json")
return
}
ceremony, err := a.takeCeremony(r, "registration")
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
user := a.store.userByEmail(ceremony.Email)
if user == nil {
writeError(w, http.StatusBadRequest, "registration expired")
return
}
credential, err := a.webAuthn.FinishRegistration(user, ceremony.Data, r)
if err != nil {
writeError(w, http.StatusBadRequest, "passkey registration could not be verified")
return
}
if err := a.store.addFirstCredential(ceremony.Email, *credential); err != nil {
writeError(w, http.StatusConflict, err.Error())
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"message": "passkey registered",
"credential": summarizeCredential(credential),
})
}
Step 7: Add the registration page
Create static/index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Passkey workshop</title>
</head>
<body>
<h1>Register a passkey</h1>
<label>Email <input id="email" type="email" autocomplete="off" data-1p-ignore data-bwignore="true" /></label>
<button id="register" type="button">Create passkey</button>
<pre id="status">Ready.</pre>
<script src="/app.js" defer></script>
</body>
</html>
Create static/app.js. WebAuthn uses binary values, while JSON carries text. These helpers convert the challenge, user ID, credential ID, client data, and attestation object to and from Base64URL.
autocomplete="off" asks the browser not to fill the email. data-1p-ignore and data-bwignore="true" ask the 1Password and Bitwarden extensions to ignore the field. These attributes affect form filling only. They cannot stop an extension from replacing the browser’s WebAuthn prompt when navigator.credentials.create() runs. To prevent that prompt during the workshop, disable passkey saving in the extension or exclude localhost in the extension settings.
const statusBox = document.querySelector("#status");
const emailInput = document.querySelector("#email");
function show(value) {
statusBox.textContent = typeof value === "string" ? value : JSON.stringify(value, null, 2);
}
function decodeBase64URL(value) {
const base64 = value.replaceAll("-", "+").replaceAll("_", "/");
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
return Uint8Array.from(atob(padded), (character) => character.charCodeAt(0));
}
function encodeBase64URL(value) {
const bytes = new Uint8Array(value);
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
}
function creationOptionsFromJSON(options) {
options.challenge = decodeBase64URL(options.challenge);
options.user.id = decodeBase64URL(options.user.id);
options.excludeCredentials = (options.excludeCredentials || []).map((credential) => ({
...credential,
id: decodeBase64URL(credential.id),
}));
return options;
}
function credentialToJSON(credential) {
return {
id: credential.id,
rawId: encodeBase64URL(credential.rawId),
type: credential.type,
authenticatorAttachment: credential.authenticatorAttachment,
clientExtensionResults: credential.getClientExtensionResults(),
response: {
clientDataJSON: encodeBase64URL(credential.response.clientDataJSON),
attestationObject: encodeBase64URL(credential.response.attestationObject),
transports: credential.response.getTransports?.() || [],
},
};
}
async function api(path, options) {
const response = await fetch(path, options);
const body = await response.json();
if (!response.ok) throw new Error(`${response.status}: ${body.error}`);
return body;
}
document.querySelector("#register").addEventListener("click", async () => {
try {
show("Starting registration…");
const creation = await api("/api/passkeys/registration/begin", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: emailInput.value }),
});
const credential = await navigator.credentials.create({
publicKey: creationOptionsFromJSON(creation.publicKey),
});
show(await api("/api/passkeys/registration/finish", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(credentialToJSON(credential)),
}));
} catch (error) {
show(error.message);
}
});
Create cmd/passkeyauth/main.go:
package main
import (
"log"
"net/http"
"time"
"passkeyauth/internal/auth"
)
func main() {
app, err := auth.New(auth.Config{
RPDisplayName: "Passkey Workshop",
RPID: "localhost",
RPOrigins: []string{"http://localhost:8080"},
StaticDir: "static",
})
if err != nil {
log.Fatal(err)
}
server := &http.Server{
Addr: ":8080",
Handler: app.Routes(),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
log.Println("open http://localhost:8080")
log.Fatal(server.ListenAndServe())
}
Format and run this first checkpoint:
go mod tidy
gofmt -w cmd internal
go run ./cmd/passkeyauth
Open http://localhost:8080, enter an email, and select Create passkey. After approving the authenticator prompt, expect passkey registered.
Stop here until registration works. At this point the server has a verified public-key credential, but it has no way to log in and no session.
Why does registration not sign us in? Checkpoint 1 has not introduced sessions yet, so registration stops after saving the credential. Most production signup flows create a session after
FinishRegistrationverifies the new passkey. That lets the user create a passkey and continue as a signed-in user without a second passkey prompt. Verify the email address before creating that account and session. This workshop accepts any email only to keep the local example focused.
How registration moves through the application
Before adding login, follow the registration checkpoint from top to bottom. Store.users and Store.ceremonies are shown separately because they have different jobs, although both are maps inside the same in-memory Store.
sequenceDiagram
actor User
participant HTML as index.html
participant JS as app.js
participant Handler as registration.go
participant Users as Store.users
participant WebAuthn as go-webauthn
participant Ceremonies as Store.ceremonies
participant Authenticator
User->>HTML: Enter email and select Create passkey
HTML->>JS: Click event
JS->>Handler: POST registration/begin with email
Handler->>Users: Load or create user
Users-->>Handler: User snapshot
Handler->>WebAuthn: BeginRegistration(user)
WebAuthn-->>Handler: Public options and private SessionData
Handler->>Ceremonies: Save email, kind, SessionData, expiry
Handler-->>JS: Options and HttpOnly ceremony cookie
JS->>Authenticator: navigator.credentials.create(options)
Authenticator-->>JS: New public-key credential
JS->>Handler: POST registration/finish with credential and cookie
Handler->>Ceremonies: Take and delete ceremony
Ceremonies-->>Handler: Saved SessionData
Handler->>WebAuthn: FinishRegistration(user, SessionData, response)
WebAuthn-->>Handler: Verified credential
Handler->>Users: Store credential ID, public key, and metadata
Handler-->>JS: 200 passkey registered
JS->>HTML: Show success
The important split is between public and private ceremony data. The browser receives the public creation options. The server keeps SessionData, including the expected challenge, and gives the browser only a random cookie that identifies that saved ceremony. During the finish request, the server consumes that ceremony before verifying and storing the credential.
Checkpoint 2: add login and a session
Login will verify a fresh signature and exchange it for a normal application session. We still will not add profile updates or CSRF.
Create the files used by this checkpoint:
touch internal/auth/login.go internal/auth/middleware.go internal/auth/session.go
Step 8: Expand the store for login
Add a session cookie name and lifetime to the constants in app.go:
const (
ceremonyCookie = "passkey_ceremony"
+ sessionCookie = "session"
ceremonyTTL = 5 * time.Minute
+ sessionTTL = 24 * time.Hour
)
Add Session and the sessions map to store.go:
+type Session struct {
+ Email string
+ ExpiresAt time.Time
+}
+
type Store struct {
mu sync.RWMutex
users map[string]*User
ceremonies map[string]Ceremony
+ sessions map[string]Session
}
func newStore() *Store {
return &Store{
users: make(map[string]*User),
ceremonies: make(map[string]Ceremony),
+ sessions: make(map[string]Session),
}
}
Also add this method to store.go. FinishLogin does more than verify the signature. It returns the credential with its latest login state.
Some authenticators increase SignCount each time they use a passkey. The library compares the new count with the stored count. It saves the higher count, or sets CloneWarning when the count should have increased but did not. This warning means the passkey may have been copied. The library can also remember that the authenticator verified the user and whether a synced passkey is currently backed up.
We replace the stored credential with this returned value so the next login uses the latest state. If we kept the old value, the next login would compare against an old counter and could produce the wrong clone warning.
func (s *Store) replaceCredential(email string, credential webauthn.Credential) bool {
s.mu.Lock()
defer s.mu.Unlock()
user := s.users[email]
if user == nil {
return false
}
for i := range user.Credentials {
if bytes.Equal(user.Credentials[i].ID, credential.ID) {
user.Credentials[i] = credential
return true
}
}
return false
}
Step 9: Sign in with the passkey
Create internal/auth/login.go.
Login has the same begin/finish shape.
beginLogin uses the email to load the account and its credentials. BeginLogin creates a fresh challenge and puts the user’s credential IDs in allowCredentials.
The browser will pass those options to navigator.credentials.get(). The authenticator uses the private key to sign the authenticator data and a hash of the client data. It does not send the private key.
finishLogin calls FinishLogin. The library verifies the challenge, origin, RP ID, credential ownership, user-verification policy, and signature. The server deletes the ceremony before verification, so the same signed response cannot be submitted again.
The returned credential may contain an updated signature counter, clone warning, user-verification state, or backup state. We replace the stored credential before creating the application session. The workshop returns the same credential summary in the login response so you can compare it with the registration response.
The handler returns the same error when an email is missing or has no passkey. This makes it harder to test which email addresses have accounts. Timing and response details can still reveal that information, so production systems also need rate limits and the protections described by the WebAuthn specification.
package auth
import (
"net/http"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
)
func (a *App) beginLogin(w http.ResponseWriter, r *http.Request) {
if !isJSON(r) {
writeError(w, http.StatusUnsupportedMediaType, "use application/json")
return
}
email, err := readEmail(r)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
user := a.store.userByEmail(email)
if user == nil || len(user.Credentials) == 0 {
writeError(w, http.StatusUnauthorized, "sign-in could not be started")
return
}
options, data, err := a.webAuthn.BeginLogin(
user,
webauthn.WithUserVerification(protocol.VerificationRequired),
)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not start sign-in")
return
}
a.saveCeremony(w, email, "login", *data)
writeJSON(w, http.StatusOK, options)
}
func (a *App) finishLogin(w http.ResponseWriter, r *http.Request) {
if !isJSON(r) {
writeError(w, http.StatusUnsupportedMediaType, "use application/json")
return
}
ceremony, err := a.takeCeremony(r, "login")
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
user := a.store.userByEmail(ceremony.Email)
if user == nil {
writeError(w, http.StatusUnauthorized, "sign-in failed")
return
}
credential, err := a.webAuthn.FinishLogin(user, ceremony.Data, r)
if err != nil {
writeError(w, http.StatusUnauthorized, "passkey assertion could not be verified")
return
}
if !a.store.replaceCredential(user.Email, *credential) {
writeError(w, http.StatusInternalServerError, "credential state could not be saved")
return
}
token, _ := a.createSession(user.Email)
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: token,
Path: "/",
HttpOnly: true,
Secure: false, // Set true behind HTTPS in production.
SameSite: http.SameSiteLaxMode,
MaxAge: int(sessionTTL.Seconds()),
})
writeJSON(w, http.StatusOK, map[string]any{
"message": "signed in",
"credential": summarizeCredential(credential),
})
}
Step 10: Create and read a session
Create internal/auth/middleware.go with session handling only:
package auth
import (
"net/http"
"strings"
"time"
)
type sessionHandler func(http.ResponseWriter, *http.Request, Session)
func (a *App) requireSession(next sessionHandler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(sessionCookie)
if err != nil {
writeError(w, http.StatusUnauthorized, "sign in first")
return
}
a.store.mu.RLock()
session, ok := a.store.sessions[cookie.Value]
a.store.mu.RUnlock()
if !ok || time.Now().After(session.ExpiresAt) {
writeError(w, http.StatusUnauthorized, "session is missing or expired")
return
}
next(w, r, session)
}
}
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'")
w.Header().Set("X-Content-Type-Options", "nosniff")
if strings.HasPrefix(r.URL.Path, "/api/") {
w.Header().Set("Cache-Control", "no-store")
}
next.ServeHTTP(w, r)
})
}
Create internal/auth/session.go:
package auth
import (
"net/http"
"time"
)
func (a *App) getSession(w http.ResponseWriter, _ *http.Request, session Session) {
writeJSON(w, http.StatusOK, map[string]string{"email": session.Email})
}
func (a *App) createSession(email string) (string, Session) {
token := randomToken(32)
session := Session{Email: email, ExpiresAt: time.Now().Add(sessionTTL)}
a.store.mu.Lock()
a.store.sessions[token] = session
a.store.mu.Unlock()
return token, session
}
The random session ID is stored in an HttpOnly cookie by finishLogin. The browser sends that cookie automatically. JavaScript cannot read it. The actual session record stays on the server.
Add the login and session routes to Routes in app.go, and wrap the mux with the security headers:
mux.HandleFunc("POST /api/passkeys/registration/finish", a.finishRegistration)
+mux.HandleFunc("POST /api/passkeys/login/begin", a.beginLogin)
+mux.HandleFunc("POST /api/passkeys/login/finish", a.finishLogin)
+mux.HandleFunc("GET /api/session", a.requireSession(a.getSession))
mux.Handle("/", http.FileServer(http.Dir(a.staticDir)))
-return mux
+return securityHeaders(mux)
Step 11: Expand the browser for login
Add these buttons after the registration button in static/index.html:
<button id="login" type="button">Sign in with passkey</button>
<button id="load-session" type="button">Load session</button>
In static/app.js, add the conversion for login options:
function requestOptionsFromJSON(options) {
options.challenge = decodeBase64URL(options.challenge);
options.allowCredentials = (options.allowCredentials || []).map((credential) => ({
...credential,
id: decodeBase64URL(credential.id),
}));
return options;
}
Replace credentialToJSON with this expanded version. Registration responses contain an attestation object. Login responses contain authenticator data and a signature.
function credentialToJSON(credential) {
const response = {
clientDataJSON: encodeBase64URL(credential.response.clientDataJSON),
};
if (credential.response.attestationObject) {
response.attestationObject = encodeBase64URL(credential.response.attestationObject);
response.transports = credential.response.getTransports?.() || [];
} else {
response.authenticatorData = encodeBase64URL(credential.response.authenticatorData);
response.signature = encodeBase64URL(credential.response.signature);
response.userHandle = credential.response.userHandle
? encodeBase64URL(credential.response.userHandle)
: null;
}
return {
id: credential.id,
rawId: encodeBase64URL(credential.rawId),
type: credential.type,
authenticatorAttachment: credential.authenticatorAttachment,
clientExtensionResults: credential.getClientExtensionResults(),
response,
};
}
Give api a default options value, then add the two button handlers:
-async function api(path, options) {
+async function api(path, options = {}) {
document.querySelector("#login").addEventListener("click", async () => {
try {
show("Starting sign-in…");
const request = await api("/api/passkeys/login/begin", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: emailInput.value }),
});
const credential = await navigator.credentials.get({
publicKey: requestOptionsFromJSON(request.publicKey),
});
show(await api("/api/passkeys/login/finish", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(credentialToJSON(credential)),
}));
} catch (error) {
show(error.message);
}
});
document.querySelector("#load-session").addEventListener("click", async () => {
try {
show(await api("/api/session"));
} catch (error) {
show(error.message);
}
});
Restart the server. Register, sign in, then select Load session. It should return your email. That proves this chain:
verified passkey signature -> random session ID -> HttpOnly cookie -> server-side session
There is still no CSRF token because every authenticated endpoint is read-only.
Checkpoint 3: add a state change, then CSRF
Next, we’ll add a profile update endpoint. It changes data and uses the session cookie, so it needs CSRF protection.
Step 12: See the cookie problem
After sign-in, the browser automatically sends the session cookie with requests to this site. JavaScript does not need to read the cookie first. This means a request may act as the signed-in user even when another page caused it.
Passkeys do not change this. The passkey authenticated the login ceremony. Later profile requests use the cookie, not the passkey.
SameSite=Lax, strict JSON content types, and restrictive CORS already stop many cross-site requests. However, each protection covers different request types. We will make every state-changing handler check a CSRF token as well.
The initial registration and login routes do not use an authenticated session to change an existing account, so this workshop does not put a CSRF token on them. Their temporary ceremony cookie is SameSite=Strict, short-lived, and consumed once. An authenticated add another passkey route would need CSRF protection.
First imagine this incomplete route:
mux.HandleFunc("POST /api/profile", a.requireSession(a.updateProfile))
This route checks only whether the request has a valid session cookie. It does not check whether our page intentionally sent the request. We need a second check for that.
Step 13: Add the CSRF token
We will store a CSRF token in each server-side session. This approach is often called the synchronizer-token pattern:
- Create a random CSRF token with the session.
- Keep it in the server-side session.
- Return it from
GET /api/session. - Send it in
X-CSRF-Tokenfor state-changing requests. - Compare it on the server before running the handler.
The session ID and CSRF token are independent. The session ID remains in the HttpOnly cookie. JavaScript receives only the CSRF token.
Now replace the growing files with their final versions. At this point it is useful to see each whole file again; these are the versions that remain in the finished project.
Final internal/auth/store.go
Add ProfileName, CSRFToken, and the profile update method to store.go:
type User struct {
ID []byte
Email string
+ ProfileName string
Credentials []webauthn.Credential
}
type Session struct {
Email string
+ CSRFToken string
ExpiresAt time.Time
}
func (s *Store) userForRegistration(email string) (*User, error) {
// ...
if user == nil {
- user = &User{ID: randomBytes(32), Email: email}
+ user = &User{ID: randomBytes(32), Email: email, ProfileName: email}
s.users[email] = user
}
// ...
}
+func (s *Store) updateProfileName(email, profileName string) bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ user := s.users[email]
+ if user == nil {
+ return false
+ }
+ user.ProfileName = profileName
+ return true
+}
Now replace store.go with the complete version:
package auth
import (
"bytes"
"errors"
"sync"
"time"
"github.com/go-webauthn/webauthn/webauthn"
)
// User is one account. It implements the webauthn.User interface.
type User struct {
ID []byte
Email string
ProfileName string
Credentials []webauthn.Credential
}
func (u *User) WebAuthnID() []byte { return u.ID }
func (u *User) WebAuthnName() string { return u.Email }
func (u *User) WebAuthnDisplayName() string { return u.Email }
func (u *User) WebAuthnCredentials() []webauthn.Credential { return u.Credentials }
// Ceremony is one in-flight registration or login exchange.
type Ceremony struct {
Email string
Kind string
Data webauthn.SessionData
ExpiresAt time.Time
}
// Session is one authenticated browser session.
type Session struct {
Email string
CSRFToken string
ExpiresAt time.Time
}
// Store is an in-memory stand-in for a database.
type Store struct {
mu sync.RWMutex
users map[string]*User
ceremonies map[string]Ceremony
sessions map[string]Session
}
func newStore() *Store {
return &Store{
users: make(map[string]*User),
ceremonies: make(map[string]Ceremony),
sessions: make(map[string]Session),
}
}
func cloneUser(user *User) *User {
if user == nil {
return nil
}
copy := *user
copy.ID = bytes.Clone(user.ID)
copy.Credentials = append([]webauthn.Credential(nil), user.Credentials...)
return ©
}
func (s *Store) userByEmail(email string) *User {
s.mu.RLock()
defer s.mu.RUnlock()
return cloneUser(s.users[email])
}
func (s *Store) userForRegistration(email string) (*User, error) {
s.mu.Lock()
defer s.mu.Unlock()
user := s.users[email]
if user == nil {
user = &User{ID: randomBytes(32), Email: email, ProfileName: email}
s.users[email] = user
}
if len(user.Credentials) > 0 {
return nil, errors.New("this workshop account already has a passkey")
}
return cloneUser(user), nil
}
func (s *Store) addFirstCredential(email string, credential webauthn.Credential) error {
s.mu.Lock()
defer s.mu.Unlock()
user := s.users[email]
if user == nil {
return errors.New("registration expired")
}
if len(user.Credentials) > 0 {
return errors.New("this workshop account already has a passkey")
}
for _, otherUser := range s.users {
for _, existing := range otherUser.Credentials {
if bytes.Equal(existing.ID, credential.ID) {
return errors.New("credential is already registered")
}
}
}
user.Credentials = append(user.Credentials, credential)
return nil
}
func (s *Store) replaceCredential(email string, credential webauthn.Credential) bool {
s.mu.Lock()
defer s.mu.Unlock()
user := s.users[email]
if user == nil {
return false
}
for i := range user.Credentials {
if bytes.Equal(user.Credentials[i].ID, credential.ID) {
user.Credentials[i] = credential
return true
}
}
return false
}
func (s *Store) updateProfileName(email, profileName string) bool {
s.mu.Lock()
defer s.mu.Unlock()
user := s.users[email]
if user == nil {
return false
}
user.ProfileName = profileName
return true
}
Final internal/auth/app.go
Both state-changing routes require the session and the CSRF token. Logout needs protection too because it changes server state.
mux.HandleFunc("GET /api/session", a.requireSession(a.getSession))
+mux.HandleFunc("POST /api/profile", a.requireSession(a.requireCSRF(a.updateProfile)))
+mux.HandleFunc("POST /api/logout", a.requireSession(a.requireCSRF(a.logout)))
mux.Handle("/", http.FileServer(http.Dir(a.staticDir)))
Now replace app.go with the complete version:
package auth
import (
"net/http"
"time"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
)
const (
ceremonyCookie = "passkey_ceremony"
sessionCookie = "session"
ceremonyTTL = 5 * time.Minute
sessionTTL = 24 * time.Hour
)
// Config holds the relying-party settings for one deployment.
type Config struct {
RPDisplayName string
RPID string
RPOrigins []string
StaticDir string
}
// App wires the WebAuthn library to the HTTP handlers.
type App struct {
webAuthn *webauthn.WebAuthn
store *Store
staticDir string
}
// New builds an App from config.
func New(cfg Config) (*App, error) {
web, err := webauthn.New(&webauthn.Config{
RPDisplayName: cfg.RPDisplayName,
RPID: cfg.RPID,
RPOrigins: cfg.RPOrigins,
AttestationPreference: protocol.PreferNoAttestation,
AuthenticatorSelection: protocol.AuthenticatorSelection{
ResidentKey: protocol.ResidentKeyRequirementRequired,
UserVerification: protocol.VerificationRequired,
},
})
if err != nil {
return nil, err
}
if cfg.StaticDir == "" {
cfg.StaticDir = "static"
}
return &App{webAuthn: web, store: newStore(), staticDir: cfg.StaticDir}, nil
}
// Routes returns the full HTTP handler.
func (a *App) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("POST /api/passkeys/registration/begin", a.beginRegistration)
mux.HandleFunc("POST /api/passkeys/registration/finish", a.finishRegistration)
mux.HandleFunc("POST /api/passkeys/login/begin", a.beginLogin)
mux.HandleFunc("POST /api/passkeys/login/finish", a.finishLogin)
mux.HandleFunc("GET /api/session", a.requireSession(a.getSession))
mux.HandleFunc("POST /api/profile", a.requireSession(a.requireCSRF(a.updateProfile)))
mux.HandleFunc("POST /api/logout", a.requireSession(a.requireCSRF(a.logout)))
mux.Handle("/", http.FileServer(http.Dir(a.staticDir)))
return securityHeaders(mux)
}
Final internal/auth/middleware.go
requireCSRF uses a constant-time comparison and rejects an empty, wrong, or missing token.
package auth
import (
"crypto/subtle"
"net/http"
"strings"
"time"
)
type sessionHandler func(http.ResponseWriter, *http.Request, Session)
func (a *App) requireSession(next sessionHandler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(sessionCookie)
if err != nil {
writeError(w, http.StatusUnauthorized, "sign in first")
return
}
a.store.mu.RLock()
session, ok := a.store.sessions[cookie.Value]
a.store.mu.RUnlock()
if !ok || time.Now().After(session.ExpiresAt) {
writeError(w, http.StatusUnauthorized, "session is missing or expired")
return
}
next(w, r, session)
}
}
func (a *App) requireCSRF(next sessionHandler) sessionHandler {
return func(w http.ResponseWriter, r *http.Request, session Session) {
got := r.Header.Get("X-CSRF-Token")
if got == "" || subtle.ConstantTimeCompare([]byte(got), []byte(session.CSRFToken)) != 1 {
writeError(w, http.StatusForbidden, "missing or invalid CSRF token")
return
}
next(w, r, session)
}
}
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'")
w.Header().Set("X-Content-Type-Options", "nosniff")
if strings.HasPrefix(r.URL.Path, "/api/") {
w.Header().Set("Cache-Control", "no-store")
}
next.ServeHTTP(w, r)
})
}
Final internal/auth/session.go
createSession now generates the second random value. getSession returns it after session authentication.
package auth
import (
"encoding/json"
"net/http"
"strings"
"time"
)
func (a *App) getSession(w http.ResponseWriter, _ *http.Request, session Session) {
user := a.store.userByEmail(session.Email)
if user == nil {
writeError(w, http.StatusUnauthorized, "session user no longer exists")
return
}
writeJSON(w, http.StatusOK, map[string]string{
"email": user.Email,
"profileName": user.ProfileName,
"csrfToken": session.CSRFToken,
})
}
func (a *App) updateProfile(w http.ResponseWriter, r *http.Request, session Session) {
if !isJSON(r) {
writeError(w, http.StatusUnsupportedMediaType, "use application/json")
return
}
var body struct {
ProfileName string `json:"profileName"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
body.ProfileName = strings.TrimSpace(body.ProfileName)
if body.ProfileName == "" || len(body.ProfileName) > 80 {
writeError(w, http.StatusBadRequest, "profile name must be 1 to 80 characters")
return
}
if !a.store.updateProfileName(session.Email, body.ProfileName) {
writeError(w, http.StatusUnauthorized, "session user no longer exists")
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "profile updated"})
}
func (a *App) logout(w http.ResponseWriter, r *http.Request, _ Session) {
cookie, _ := r.Cookie(sessionCookie)
if cookie != nil {
a.store.mu.Lock()
delete(a.store.sessions, cookie.Value)
a.store.mu.Unlock()
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: "",
Path: "/",
HttpOnly: true,
Secure: false,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
writeJSON(w, http.StatusOK, map[string]string{"message": "signed out"})
}
func (a *App) createSession(email string) (string, Session) {
token := randomToken(32)
session := Session{Email: email, CSRFToken: randomToken(32), ExpiresAt: time.Now().Add(sessionTTL)}
a.store.mu.Lock()
a.store.sessions[token] = session
a.store.mu.Unlock()
return token, session
}
Final browser files
Replace static/index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Passkey workshop</title>
<style>
body { font: 16px/1.5 system-ui, sans-serif; max-width: 42rem; margin: 3rem auto; padding: 0 1rem; }
form, section { border: 1px solid #bbb; padding: 1rem; margin: 1rem 0; }
input, button { font: inherit; padding: .55rem; margin: .25rem 0; }
input { width: min(24rem, 90%); }
#status { white-space: pre-wrap; background: #f3f3f3; padding: 1rem; min-height: 3rem; }
</style>
</head>
<body>
<h1>Passkey authentication workshop</h1>
<form id="auth-form">
<label>Email<br /><input id="email" type="email" required autocomplete="off" data-1p-ignore data-bwignore="true" /></label><br />
<button id="register" type="button">Create passkey</button>
<button id="login" type="button">Sign in with passkey</button>
</form>
<section>
<h2>Protected profile</h2>
<button id="load-session" type="button">Load session</button><br />
<label>Profile name<br /><input id="profile-name" autocomplete="off" data-1p-ignore data-bwignore="true" /></label><br />
<button id="save-profile" type="button">Save with CSRF token</button>
<button id="save-without-csrf" type="button">Try without CSRF token</button>
<button id="logout" type="button">Sign out</button>
</section>
<pre id="status">Ready.</pre>
<script src="/app.js" defer></script>
</body>
</html>
Replace static/app.js:
let csrfToken = "";
const statusBox = document.querySelector("#status");
const emailInput = document.querySelector("#email");
const profileNameInput = document.querySelector("#profile-name");
function show(value) {
statusBox.textContent = typeof value === "string" ? value : JSON.stringify(value, null, 2);
}
function decodeBase64URL(value) {
const base64 = value.replaceAll("-", "+").replaceAll("_", "/");
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
return Uint8Array.from(atob(padded), (character) => character.charCodeAt(0));
}
function encodeBase64URL(value) {
const bytes = new Uint8Array(value);
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
}
function creationOptionsFromJSON(options) {
options.challenge = decodeBase64URL(options.challenge);
options.user.id = decodeBase64URL(options.user.id);
options.excludeCredentials = (options.excludeCredentials || []).map((credential) => ({
...credential,
id: decodeBase64URL(credential.id),
}));
return options;
}
function requestOptionsFromJSON(options) {
options.challenge = decodeBase64URL(options.challenge);
options.allowCredentials = (options.allowCredentials || []).map((credential) => ({
...credential,
id: decodeBase64URL(credential.id),
}));
return options;
}
function credentialToJSON(credential) {
const response = {
clientDataJSON: encodeBase64URL(credential.response.clientDataJSON),
};
if (credential.response.attestationObject) {
response.attestationObject = encodeBase64URL(credential.response.attestationObject);
response.transports = credential.response.getTransports?.() || [];
} else {
response.authenticatorData = encodeBase64URL(credential.response.authenticatorData);
response.signature = encodeBase64URL(credential.response.signature);
response.userHandle = credential.response.userHandle
? encodeBase64URL(credential.response.userHandle)
: null;
}
return {
id: credential.id,
rawId: encodeBase64URL(credential.rawId),
type: credential.type,
authenticatorAttachment: credential.authenticatorAttachment,
clientExtensionResults: credential.getClientExtensionResults(),
response,
};
}
async function api(path, options = {}) {
const response = await fetch(path, options);
const body = await response.json();
if (!response.ok) throw new Error(`${response.status}: ${body.error}`);
return body;
}
document.querySelector("#register").addEventListener("click", async () => {
try {
show("Starting registration…");
const creation = await api("/api/passkeys/registration/begin", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: emailInput.value }),
});
const credential = await navigator.credentials.create({
publicKey: creationOptionsFromJSON(creation.publicKey),
});
const result = await api("/api/passkeys/registration/finish", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(credentialToJSON(credential)),
});
show(result);
} catch (error) {
show(error.message);
}
});
document.querySelector("#login").addEventListener("click", async () => {
try {
show("Starting sign-in…");
const request = await api("/api/passkeys/login/begin", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: emailInput.value }),
});
const credential = await navigator.credentials.get({
publicKey: requestOptionsFromJSON(request.publicKey),
});
const result = await api("/api/passkeys/login/finish", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(credentialToJSON(credential)),
});
const session = await api("/api/session");
csrfToken = session.csrfToken;
profileNameInput.value = session.profileName;
show({
...result,
session: { email: session.email, profileName: session.profileName },
});
} catch (error) {
show(error.message);
}
});
document.querySelector("#load-session").addEventListener("click", async () => {
try {
const session = await api("/api/session");
csrfToken = session.csrfToken;
profileNameInput.value = session.profileName;
show({ email: session.email, profileName: session.profileName });
} catch (error) {
show(error.message);
}
});
async function saveProfile(includeToken) {
try {
const headers = { "Content-Type": "application/json" };
if (includeToken) headers["X-CSRF-Token"] = csrfToken;
show(await api("/api/profile", {
method: "POST",
headers,
body: JSON.stringify({ profileName: profileNameInput.value }),
}));
} catch (error) {
show(error.message);
}
}
document.querySelector("#save-profile").addEventListener("click", () => saveProfile(true));
document.querySelector("#save-without-csrf").addEventListener("click", () => saveProfile(false));
document.querySelector("#logout").addEventListener("click", async () => {
try {
show(await api("/api/logout", { method: "POST", headers: { "X-CSRF-Token": csrfToken } }));
csrfToken = "";
} catch (error) {
show(error.message);
}
});
After login, the script loads the session and stores its CSRF token only in memory. The Load session button can load it again. Try without CSRF token deliberately omits the header. Save with CSRF token sends it.
Step 14: Prove that CSRF protection works
Create internal/auth/app_test.go.
The main table sends four profile updates:
| Request | Expected result |
|---|---|
| No session cookie | 401 Unauthorized |
| Valid session, no CSRF token | 403 Forbidden |
| Valid session, wrong CSRF token | 403 Forbidden |
| Valid session, matching CSRF token | 200 OK |
The test supplies the session cookie directly. It does not depend on a browser or on SameSite. This proves that the handler itself rejects a request without the correct CSRF token.
The file also checks that application/jsonp is rejected and that one credential ID cannot belong to two accounts.
package auth
import (
"encoding/base64"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
)
func TestSummarizeCredential(t *testing.T) {
credential := &webauthn.Credential{
ID: []byte("credential-id"),
PublicKey: []byte("public-key"),
AttestationType: "none",
AttestationFormat: "none",
Transport: []protocol.AuthenticatorTransport{"internal"},
Flags: webauthn.NewCredentialFlags(protocol.FlagUserPresent | protocol.FlagUserVerified | protocol.FlagBackupEligible),
Authenticator: webauthn.Authenticator{
AAGUID: []byte("aaguid"),
Attachment: protocol.Platform,
SignCount: 7,
CloneWarning: true,
},
}
summary := summarizeCredential(credential)
if summary.IDBase64URL != base64.RawURLEncoding.EncodeToString(credential.ID) {
t.Fatalf("IDBase64URL = %q", summary.IDBase64URL)
}
if summary.PublicKeyBase64URL != base64.RawURLEncoding.EncodeToString(credential.PublicKey) {
t.Fatalf("PublicKeyBase64URL = %q", summary.PublicKeyBase64URL)
}
if !summary.Flags.UserVerified || !summary.Flags.BackupEligible {
t.Fatalf("Flags = %+v", summary.Flags)
}
if summary.Authenticator.SignCount != 7 || !summary.Authenticator.CloneWarning {
t.Fatalf("Authenticator = %+v", summary.Authenticator)
}
}
func TestProfileRequiresSessionAndCSRFToken(t *testing.T) {
app, err := New(Config{
RPDisplayName: "Passkey Workshop",
RPID: "localhost",
RPOrigins: []string{"http://localhost:8080"},
})
if err != nil {
t.Fatal(err)
}
app.store.users["[email protected]"] = &User{
ID: randomBytes(32),
Email: "[email protected]",
ProfileName: "Reader",
}
sessionToken, session := app.createSession("[email protected]")
handler := app.Routes()
tests := []struct {
name string
cookie bool
csrfToken string
wantStatus int
}{
{name: "no session", wantStatus: http.StatusUnauthorized},
{name: "session but no CSRF token", cookie: true, wantStatus: http.StatusForbidden},
{name: "wrong CSRF token", cookie: true, csrfToken: "wrong", wantStatus: http.StatusForbidden},
{name: "matching CSRF token", cookie: true, csrfToken: session.CSRFToken, wantStatus: http.StatusOK},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/profile", strings.NewReader(`{"profileName":"New name"}`))
req.Header.Set("Content-Type", "application/json")
if tt.cookie {
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: sessionToken})
}
if tt.csrfToken != "" {
req.Header.Set("X-CSRF-Token", tt.csrfToken)
}
response := httptest.NewRecorder()
handler.ServeHTTP(response, req)
if response.Code != tt.wantStatus {
t.Fatalf("status = %d, want %d; body = %s", response.Code, tt.wantStatus, response.Body.String())
}
})
}
}
func TestBeginLoginRequiresExactJSONMediaType(t *testing.T) {
app, err := New(Config{
RPDisplayName: "Passkey Workshop",
RPID: "localhost",
RPOrigins: []string{"http://localhost:8080"},
})
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/api/passkeys/login/begin", strings.NewReader(`{"email":"[email protected]"}`))
req.Header.Set("Content-Type", "application/jsonp")
response := httptest.NewRecorder()
app.Routes().ServeHTTP(response, req)
if response.Code != http.StatusUnsupportedMediaType {
t.Fatalf("status = %d, want %d; body = %s", response.Code, http.StatusUnsupportedMediaType, response.Body.String())
}
}
func TestStoreRejectsCredentialOwnedByAnotherUser(t *testing.T) {
store := newStore()
store.users["[email protected]"] = &User{ID: randomBytes(32), Email: "[email protected]"}
store.users["[email protected]"] = &User{
ID: randomBytes(32),
Email: "[email protected]",
Credentials: []webauthn.Credential{{ID: []byte("same-credential")}},
}
err := store.addFirstCredential("[email protected]", webauthn.Credential{ID: []byte("same-credential")})
if err == nil {
t.Fatal("expected duplicate credential to be rejected")
}
}
Create static/app.test.mjs. This test replaces the browser and server with small fakes. It signs in, immediately saves the profile, and checks that the profile request contains the token loaded from /api/session. This catches the bug where the button sent an empty token until someone selected Load session.
import assert from "node:assert/strict";
import test from "node:test";
test("login loads the CSRF token before a protected request", async () => {
const handlers = new Map();
const elements = new Map();
for (const selector of [
"#status",
"#email",
"#profile-name",
"#register",
"#login",
"#load-session",
"#save-profile",
"#save-without-csrf",
"#logout",
]) {
elements.set(selector, {
textContent: "",
value: selector === "#email" ? "[email protected]" : "",
addEventListener: (_event, handler) => handlers.set(selector, handler),
});
}
globalThis.document = {
querySelector: (selector) => elements.get(selector),
};
const bytes = new Uint8Array([1]).buffer;
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {
credentials: {
get: async () => ({
id: "credential-id",
rawId: bytes,
type: "public-key",
authenticatorAttachment: "platform",
getClientExtensionResults: () => ({}),
response: {
clientDataJSON: bytes,
authenticatorData: bytes,
signature: bytes,
userHandle: null,
},
}),
},
},
});
const expectedToken = "expected-csrf-token";
let profileHeaders;
globalThis.fetch = async (path, options = {}) => {
if (path === "/api/passkeys/login/begin") {
return jsonResponse({ publicKey: { challenge: "AQ", allowCredentials: [] } });
}
if (path === "/api/passkeys/login/finish") {
return jsonResponse({ message: "signed in" });
}
if (path === "/api/session") {
return jsonResponse({
email: "[email protected]",
profileName: "[email protected]",
csrfToken: expectedToken,
});
}
if (path === "/api/profile") {
profileHeaders = options.headers;
if (profileHeaders["X-CSRF-Token"] !== expectedToken) {
return jsonResponse({ error: "missing or invalid CSRF token" }, 403);
}
return jsonResponse({ message: "profile updated" });
}
throw new Error(`unexpected request: ${path}`);
};
await import(`./app.js?test=${Date.now()}`);
await handlers.get("#login")();
await handlers.get("#save-profile")();
assert.equal(profileHeaders["X-CSRF-Token"], expectedToken);
});
function jsonResponse(body, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
Run the tests:
go mod tidy
go test -race ./...
node --test static/app.test.mjs
Expected result:
? passkeyauth/cmd/passkeyauth [no test files]
ok passkeyauth/internal/auth
ℹ tests 1
ℹ pass 1
ℹ fail 0
Run the final application. The RP ID and origin must match the browser URL. Use http://localhost:8080, not http://127.0.0.1:8080.
go run ./cmd/passkeyauth
Open http://localhost:8080, then:
- Enter an email and select Create passkey.
- Approve the authenticator prompt.
- Select Sign in with passkey and approve the prompt.
- Select Load session.
- Enter a profile name and select Try without CSRF token. Expect
403. - Select Save with CSRF token. Expect
200. - Select Sign out.
- Select Load session again. Expect
401.
You have now proved three transitions:
verified registration ceremony -> stored public-key credential
verified passkey signature -> authenticated server-side session
session cookie + CSRF token -> allowed state change
Going further
This workshop allows one passkey per account. A production “add passkey” flow must require the existing authenticated session and a CSRF token, then run a new registration ceremony for that same user. Support credential names and removal so users can manage lost devices.
Because we requested a discoverable credential, a later version could remove the email prompt. BeginDiscoverableLogin starts that flow. FinishPasskeyLogin uses the returned user handle to load the account. Keep the email-first flow as a fallback only if that matches your product requirements.
Some authenticators increase a counter each time a passkey is used. The server stores the previous value and compares it with the value from the next login. If either value is greater than zero but the new value has not increased, the passkey may have been copied. This is only a warning because many synced passkeys always return zero. Do not reject a login just because the counter is zero. After a successful login, always save the updated credential returned by the library.
Before production, add:
- verified email ownership;
- durable, access-controlled storage;
- hashed session tokens where practical;
- several credentials per account and a safe recovery path;
- atomic credential-state updates for concurrent login;
- session rotation, cleanup, revocation, and idle/absolute expiry;
- HTTPS and
Securecookies; - rate limits and account-enumeration defenses;
- strict CORS and origin checks;
- audit logs that exclude challenges, assertions, cookies, and CSRF tokens;
- dependency upgrades backed by negative security tests.
Final mental model
Registration creates a website-scoped credential only after the server verifies the saved challenge and the browser’s response. Login asks that credential to sign fresh ceremony data, then exchanges the verified assertion for a session cookie. The passkey protects authentication; the CSRF token protects later state-changing requests that rely on the cookie.
Primary references: Web Authentication Level 3, go-webauthn, and the OWASP CSRF Prevention Cheat Sheet.