WebAuthn Passkeys Implementation - Go Backend for PocketBase
Published on Dec 15, 2025
Table of Contents
- Introduction
- Architecture Overview
- Go Backend Setup
- WebAuthn Library Integration
- Registration Flow
- Login Flow
- PocketBase Integration
- Production Deployment
- Frontend Integration
- Lessons Learned
- Conclusion
Introduction
Passkeys represent the future of authentication - phishing-resistant, passwordless login using cryptographic keys stored on the user’s device. For privacy-focused self-hosted applications like KnowMore, passkeys provide both security and convenience without relying on third-party authentication providers.
This post documents the complete implementation of WebAuthn passkeys for PocketBase, using a custom Go backend that extends PocketBase’s functionality while maintaining its simplicity.
Why Passkeys?
- Phishing-resistant: Passkeys are bound to specific domains, making phishing attacks impossible
- No passwords to leak: No server-side password database to compromise
- User convenience: Biometric or PIN unlock instead of typing passwords
- Cross-device support: Passkeys sync via platform ecosystems (iCloud, Google)
- Self-hosted friendly: No dependency on external authentication services
Architecture Overview
The implementation adds WebAuthn endpoints to PocketBase via a custom Go backend, compiled directly into the PocketBase binary.
Registration Flow
During registration, the user creates a new cryptographic keypair on their device. The private key never leaves the device; only the public key is stored on the server.
Step 1: Request Registration Options
The frontend requests a challenge and configuration from the backend.
Step 2: Create and Verify Credential
The browser prompts the user, creates the credential, and the backend verifies it.
Login Flow
During login, the user proves possession of the private key by signing a challenge. The server verifies this signature using the stored public key.
Step 1: Request Login Challenge
The frontend requests a challenge from the backend.
Step 2: Authenticate and Get Token
The browser prompts the user, signs the challenge, and the backend validates it.
Components
| Component | Technology | Purpose |
|---|---|---|
| Frontend | SvelteKit + @simplewebauthn/browser | WebAuthn API wrapper |
| Backend | Go + PocketBase | Custom endpoints |
| WebAuthn | go-webauthn/webauthn | Cryptographic operations |
| Database | SQLite via PocketBase | Passkey storage |
| Deployment | Docker multi-stage | Compiled Go binary |
Go Backend Setup
Project Structure
pb/
├── main.go # PocketBase entry point
├── go.mod # Go module definition
├── go.sum # Dependency checksums
├── auth/
│ └── auth.go # Shared role checks (RequireAdmin, IsAdmin, etc.)
├── webauthn/
│ └── webauthn.go # WebAuthn implementation
├── notifications/
│ ├── notifications.go # Push notifications, cron jobs
│ └── webpush.go # Web Push API integration
├── federation/
│ ├── federation.go # Webhook receiver, route registration
│ └── sync.go # Cron sync, event upsert logic
├── immich/ # Immich photo proxy (11 files)
│ ├── immich.go # Routes + lifecycle hooks
│ ├── client.go, proxy.go # HTTP client, binary streaming
│ ├── albums.go, assets.go, people.go, tags.go
│ └── admin.go, setup.go, status.go
├── hooks/ # Core lifecycle hooks (replaced JSVM)
│ ├── hooks.go # Register() orchestrator
│ ├── teams.go, users.go # Entity lifecycle
│ └── responses.go, tokens.go, routes.go
├── club_data/ # Static club data assets
└── pb_migrations/ # Database migrations (compiled Go)
└── *_collections_snapshot.go
Entry Point (main.go)
The main.go file initializes PocketBase and registers all Go plugins. Since v0.7.0, there are no JavaScript hooks — all server-side logic compiles into a single Go binary:
package main
import (
"log"
"os"
"path/filepath"
"strings"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/plugins/migratecmd"
"github.com/keesfluitman/knowmore/pb/federation"
"github.com/keesfluitman/knowmore/pb/hooks"
"github.com/keesfluitman/knowmore/pb/immich"
"github.com/keesfluitman/knowmore/pb/notifications"
"github.com/keesfluitman/knowmore/pb/webauthn"
_ "github.com/keesfluitman/knowmore/pb/pb_migrations"
)
func main() {
app := pocketbase.New()
var migrationsDir string
if strings.HasPrefix(os.Args[0], os.TempDir()) {
migrationsDir = "./pb_migrations"
} else {
execPath, _ := os.Executable()
migrationsDir = filepath.Join(filepath.Dir(execPath), "pb_migrations")
}
migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{
Dir: migrationsDir, Automigrate: true,
})
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
se.Router.GET("/api/go-health", func(e *core.RequestEvent) error {
return e.JSON(200, map[string]any{
"status": "ok", "message": "KnowMore Go backend running",
})
})
// Serve SvelteKit SPA from pb_public (bundled deployment)
publicDir := filepath.Join(filepath.Dir(os.Args[0]), "pb_public")
if _, err := os.Stat(publicDir); err == nil {
se.Router.GET("/{path...}",
apis.Static(os.DirFS(publicDir), true),
)
}
return se.Next()
})
webauthn.Register(app)
notifications.Register(app)
federation.Register(app)
immich.Register(app)
hooks.Register(app)
if err := app.Start(); err != nil {
log.Fatal(err)
}
}
Go Module (go.mod)
module github.com/keesfluitman/knowmore/pb
go 1.25
require (
github.com/go-webauthn/webauthn v0.15.0
github.com/pocketbase/pocketbase v0.36.2
github.com/SherClockHolmes/webpush-go v1.4.0
)
WebAuthn Library Integration
Configuration
The WebAuthn library requires configuration of the Relying Party (RP) - your application’s identity:
func initWebAuthn(se *core.ServeEvent) {
settings := se.App.Settings()
appURL := settings.Meta.AppURL
if appURL == "" {
appURL = "http://localhost:5173"
}
rpOrigin := strings.TrimSuffix(appURL, "/")
rpID := extractDomain(rpOrigin)
webAuthn, _ = webauthn.New(&webauthn.Config{
RPDisplayName: settings.Meta.AppName,
RPID: rpID, // e.g., "knowmore.rappedoos.com"
RPOrigins: []string{rpOrigin}, // e.g., "https://knowmore.rappedoos.com"
Timeouts: webauthn.TimeoutsConfig{
Login: webauthn.TimeoutConfig{
Enforce: true,
Timeout: time.Minute * 2,
TimeoutUVD: time.Minute * 2,
},
Registration: webauthn.TimeoutConfig{
Enforce: true,
Timeout: time.Minute * 2,
TimeoutUVD: time.Minute * 2,
},
},
})
}
func extractDomain(origin string) string {
origin = strings.TrimPrefix(origin, "https://")
origin = strings.TrimPrefix(origin, "http://")
if idx := strings.Index(origin, ":"); idx != -1 {
origin = origin[:idx]
}
if idx := strings.Index(origin, "/"); idx != -1 {
origin = origin[:idx]
}
return origin
}
The RP ID is automatically derived from PocketBase’s App URL setting, making deployment configuration straightforward.
User Interface
The go-webauthn library requires a User interface implementation:
type User struct {
ID string
Name string
DisplayName string
Credentials []webauthn.Credential
}
func (u *User) WebAuthnID() []byte {
return []byte(u.ID)
}
func (u *User) WebAuthnName() string {
return u.Name
}
func (u *User) WebAuthnDisplayName() string {
return u.DisplayName
}
func (u *User) WebAuthnCredentials() []webauthn.Credential {
return u.Credentials
}
Session Management
WebAuthn requires session data between the options request and the verification request. We use an in-memory store with automatic cleanup:
type SessionStore struct {
mu sync.RWMutex
sessions map[string]*webauthn.SessionData
}
var (
sessionStore = &SessionStore{sessions: make(map[string]*webauthn.SessionData)}
sessionTimeout = 5 * time.Minute
)
func (s *SessionStore) Set(key string, data *webauthn.SessionData) {
s.mu.Lock()
defer s.mu.Unlock()
s.sessions[key] = data
}
func (s *SessionStore) Get(key string) (*webauthn.SessionData, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
data, ok := s.sessions[key]
return data, ok
}
func (s *SessionStore) Delete(key string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.sessions, key)
}
Sessions are automatically cleaned up after 5 minutes:
sessionStore.Set(authRecord.Id, session)
go func() {
time.Sleep(sessionTimeout)
sessionStore.Delete(authRecord.Id)
}()
Registration Flow
Step 1: Get Registration Options
The frontend requests credential creation options for the authenticated user:
func handleRegistrationOptions(e *core.RequestEvent) error {
info, _ := e.RequestInfo()
authRecord := info.Auth
if authRecord == nil {
return e.JSON(401, map[string]string{"error": "Authentication required"})
}
existingCreds, _ := getExistingCredentials(e.App, authRecord.Id)
user := &User{
ID: authRecord.Id,
Name: authRecord.GetString("email"),
DisplayName: authRecord.GetString("name"),
Credentials: existingCreds,
}
excludeList := credentialsToDescriptors(user.Credentials)
options, session, err := webAuthn.BeginRegistration(user,
webauthn.WithExclusions(excludeList),
webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementPreferred),
webauthn.WithAuthenticatorSelection(protocol.AuthenticatorSelection{
AuthenticatorAttachment: protocol.CrossPlatform,
UserVerification: protocol.VerificationPreferred,
}),
)
sessionStore.Set(authRecord.Id, session)
return e.JSON(200, options)
}
Key options:
- ExcludeList: Prevents re-registering the same authenticator
- ResidentKeyRequirement: Prefers discoverable credentials for passwordless login
- CrossPlatform: Allows both platform authenticators (Touch ID) and security keys
Step 2: Verify Registration
After the user creates the credential on their device:
func handleRegister(e *core.RequestEvent) error {
info, _ := e.RequestInfo()
authRecord := info.Auth
session, ok := sessionStore.Get(authRecord.Id)
if !ok {
return e.JSON(400, map[string]string{"error": "No registration session found"})
}
sessionStore.Delete(authRecord.Id)
existingCreds, _ := getExistingCredentials(e.App, authRecord.Id)
user := &User{
ID: authRecord.Id,
Name: authRecord.GetString("email"),
DisplayName: authRecord.GetString("name"),
Credentials: existingCreds,
}
// Read body for custom name field
bodyBytes, _ := io.ReadAll(e.Request.Body)
var nameInput struct {
Name string `json:"name"`
}
json.Unmarshal(bodyBytes, &nameInput)
e.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
credential, err := webAuthn.FinishRegistration(user, *session, e.Request)
if err != nil {
return e.JSON(400, map[string]string{"error": "Registration verification failed"})
}
// Save to database
passkeysCollection, _ := e.App.FindCollectionByNameOrId("passkeys")
record := core.NewRecord(passkeysCollection)
record.Set("user", authRecord.Id)
record.Set("credential_id", base64.RawURLEncoding.EncodeToString(credential.ID))
record.Set("counter", credential.Authenticator.SignCount)
record.Set("credentials", credential) // Full credential as JSON
if nameInput.Name != "" {
record.Set("name", nameInput.Name)
} else {
record.Set("name", fmt.Sprintf("Passkey %d", len(existingCreds)+1))
}
e.App.Save(record)
return e.JSON(200, map[string]any{
"success": true,
"id": record.Id,
"name": record.GetString("name"),
})
}
Login Flow
Two Authentication Modes
The implementation supports both:
- Email-based login: User provides email, server returns options for that user’s credentials
- Discoverable login: No email needed, authenticator provides user identity
Step 1: Get Login Options
func handleLoginOptions(e *core.RequestEvent) error {
var input struct {
Email string `json:"email"`
}
if err := e.BindBody(&input); err != nil || input.Email == "" {
// Discoverable login - no email provided.
// Key the session by the challenge itself — the browser's credential
// response will echo the same challenge back, so the server can look
// up the session without any extra round-trip state.
options, session, _ := webAuthn.BeginDiscoverableLogin()
sessionKey := "discoverable:" + session.Challenge
sessionStore.Set(sessionKey, session)
return e.JSON(200, options)
}
// Email-based login
usersCollection, _ := e.App.FindCollectionByNameOrId("users")
userRecord, err := e.App.FindFirstRecordByData(usersCollection.Id, "email", input.Email)
if err != nil {
// Security: return fake options to prevent user enumeration
return e.JSON(200, generateFakeOptions())
}
credentials, _ := getExistingCredentials(e.App, userRecord.Id)
if len(credentials) == 0 {
return e.JSON(200, generateFakeOptions())
}
user := &User{
ID: userRecord.Id,
Name: userRecord.GetString("email"),
DisplayName: userRecord.GetString("name"),
Credentials: credentials,
}
options, session, _ := webAuthn.BeginLogin(user)
sessionStore.Set(userRecord.Id, session)
return e.JSON(200, options)
}
func generateFakeOptions() *protocol.CredentialAssertion {
challenge, err := protocol.CreateChallenge()
if err != nil {
challenge = protocol.URLEncodedBase64(make([]byte, 32))
}
return &protocol.CredentialAssertion{
Response: protocol.PublicKeyCredentialRequestOptions{
Challenge: challenge,
Timeout: 120000,
},
}
}
The fake options use a randomized challenge (via crypto/rand) to prevent both timing attacks and fingerprinting — a static fake challenge would reveal that no passkey exists for the given email.
Step 2: Verify Login
func handleLogin(e *core.RequestEvent) error {
// Buffer body (PocketBase middleware consumes it)
bodyBytes, _ := io.ReadAll(e.Request.Body)
e.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
parsedResponse, err := protocol.ParseCredentialRequestResponse(e.Request)
if err != nil {
return e.JSON(400, map[string]string{"error": "Invalid credential response"})
}
credentialID := base64.RawURLEncoding.EncodeToString(parsedResponse.RawID)
// Find passkey and user
passkeysCollection, _ := e.App.FindCollectionByNameOrId("passkeys")
passkeyRecord, err := e.App.FindFirstRecordByData(passkeysCollection.Id, "credential_id", credentialID)
if err != nil {
return e.JSON(401, map[string]string{"error": "Unknown credential"})
}
userId := passkeyRecord.GetString("user")
usersCollection, _ := e.App.FindCollectionByNameOrId("users")
userRecord, _ := e.App.FindRecordById(usersCollection.Id, userId)
credentials, _ := getExistingCredentials(e.App, userId)
user := &User{
ID: userRecord.Id,
Name: userRecord.GetString("email"),
DisplayName: userRecord.GetString("name"),
Credentials: credentials,
}
var credential *webauthn.Credential
// Try user-specific session first
session, ok := sessionStore.Get(userId)
if ok {
sessionStore.Delete(userId)
credential, _ = webAuthn.ValidateLogin(user, *session, parsedResponse)
} else {
// Try discoverable session — the challenge travels inside the signed
// credential response, so we re-derive the session key from there
// rather than relying on a header or extra request field.
discoverableKey := "discoverable:" + parsedResponse.Response.CollectedClientData.Challenge
session, ok = sessionStore.Get(discoverableKey)
if !ok {
return e.JSON(400, map[string]string{"error": "No login session found"})
}
sessionStore.Delete(discoverableKey)
userHandler := func(rawID, userHandle []byte) (webauthn.User, error) {
return user, nil
}
credential, _ = webAuthn.ValidateDiscoverableLogin(userHandler, *session, parsedResponse)
}
// Update counter for replay attack detection. Don't fail the login if
// this save hiccups — counter lag only matters for clone detection.
passkeyRecord.Set("counter", credential.Authenticator.SignCount)
passkeyRecord.Set("last_used", time.Now().UTC().Format(time.RFC3339))
if err := e.App.Save(passkeyRecord); err != nil {
log.Printf("[webauthn] counter update failed (passkey=%s user=%s): %v",
passkeyRecord.Id, userId, err)
}
// Generate auth token
token, _ := userRecord.NewAuthToken()
return e.JSON(200, map[string]any{
"token": token,
"record": map[string]any{
"id": userRecord.Id,
"email": userRecord.GetString("email"),
"name": userRecord.GetString("name"),
},
})
}
PocketBase Integration
Passkeys Collection Schema
The passkeys collection stores WebAuthn credentials with these fields:
| Field | Type | Purpose |
|---|---|---|
user | Relation (users) | Links to user, cascade delete |
credential_id | Text (unique) | Base64URL credential identifier |
counter | Number | Sign count for replay detection |
credentials | JSON | Full webauthn.Credential object |
name | Text | User-friendly display name |
last_used | Date | Last authentication timestamp |
created | Autodate | Registration timestamp |
updated | Autodate | Last modification |
Loading Credentials
func getExistingCredentials(app core.App, userId string) ([]webauthn.Credential, error) {
passkeysCollection, _ := app.FindCollectionByNameOrId("passkeys")
records, _ := app.FindRecordsByFilter(
passkeysCollection.Id,
"user = {:userId}",
"", 100, 0,
map[string]any{"userId": userId},
)
var credentials []webauthn.Credential
for _, record := range records {
credJSON := record.GetString("credentials")
if credJSON == "" {
continue
}
var cred webauthn.Credential
if err := json.Unmarshal([]byte(credJSON), &cred); err == nil {
credentials = append(credentials, cred)
}
}
return credentials, nil
}
Why Store Full Credential as JSON?
The WebAuthn credential contains important flags beyond just the public key:
type Credential struct {
ID []byte
PublicKey []byte
AttestationType string
Transport []AuthenticatorTransport
Flags CredentialFlags // ← Critical!
Authenticator Authenticator
}
type CredentialFlags struct {
UserPresent bool
UserVerified bool
BackupEligible bool // ← Causes validation errors if not preserved!
BackupState bool
}
Storing the entire credential as JSON ensures all flags are preserved for validation, avoiding errors like “BackupEligible flag inconsistency.”
Production Deployment
Multi-Stage Dockerfile
The bundled Dockerfile builds both the SvelteKit frontend and the Go backend into a single container:
# Stage 1: Build SvelteKit frontend
FROM node:22-alpine AS frontend-builder
RUN corepack enable
WORKDIR /app
COPY sk/package.json sk/pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY sk/ ./
ENV VITE_POCKETBASE_URL=""
RUN pnpm build
# Stage 2: Build Go binary
FROM golang:1.25-alpine AS backend-builder
WORKDIR /build
COPY pb/go.mod pb/go.sum ./
RUN go mod download
COPY pb/ ./
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o pocketbase main.go
# Stage 3: Runtime image
FROM alpine:latest
RUN apk add --no-cache ca-certificates wget
COPY --from=backend-builder /build/pocketbase /pb/pocketbase
COPY pb/pb_migrations /pb/pb_migrations
COPY --from=frontend-builder /app/build /pb/pb_public
ENV APP_DOMAIN=http://localhost:8090
RUN chown -R 99:100 /pb
USER 99:100
EXPOSE 8090
HEALTHCHECK CMD wget --spider http://localhost:8090/api/health || exit 1
CMD /pb/pocketbase serve --http=0.0.0.0:8090 --origins=${APP_DOMAIN}
The SvelteKit build uses VITE_POCKETBASE_URL="" so API calls go to the same origin — no CORS configuration needed. PocketBase serves the SPA from pb_public/ with index.html fallback for client-side routing.
Benefits:
- Single container: One port, one process, one volume to back up
- Small image: ~50-100MB runtime (vs ~300MB with build tools)
- No CGO: Static binary, no runtime dependencies
- Security: Runs as non-root user (99:100, Unraid compatible)
Domain Configuration
WebAuthn validates credentials against the domain. The Go code reads from PocketBase settings:
- Set
App URLin PocketBase admin (Settings → Application) - This becomes the RP ID and origin for WebAuthn
- Credentials registered on
knowmore.rappedoos.comonly work on that domain
Frontend Integration
The SvelteKit frontend at $lib/auth/webauthn.ts uses @simplewebauthn/browser:
import {
startRegistration,
startAuthentication
} from '@simplewebauthn/browser';
// Registration
const optionsRes = await pb.send('/api/webauthn/registration-options', { method: 'POST' });
const credential = await startRegistration(optionsRes);
await pb.send('/api/webauthn/register', {
method: 'POST',
body: { ...credential, name: passkeyName }
});
// Login
const optionsRes = await pb.send('/api/webauthn/login-options', {
method: 'POST',
body: { email }
});
const assertion = await startAuthentication(optionsRes);
const authResult = await pb.send('/api/webauthn/login', {
method: 'POST',
body: assertion
});
pb.authStore.save(authResult.token, authResult.record);Lessons Learned
1. HTTP Body Consumption (both directions)
The request body in a PocketBase route handler is a one-shot io.Reader — once it’s drained, every subsequent reader gets io.EOF. There are two distinct ways this bites a WebAuthn handler, and you need to be defensive about both.
Upstream — PocketBase middleware reads first. Depending on where in the chain your route lives, PB middleware may have already consumed the body before your handler runs. Buffer it once at the top of the handler so you have a copy you can hand back:
bodyBytes, _ := io.ReadAll(e.Request.Body)
e.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
This is the obvious one. The trickier one:
Downstream — apis.RecordAuthResponse reads it again, after you. When you finish a successful login and call:
return apis.RecordAuthResponse(e, userRecord, "passkey", nil)
PB internally calls e.RequestInfo() (apis/record_helpers.go), which lazy-initialises a RequestInfo struct via e.BindBody(&info.Body) — i.e., it reads e.Request.Body to populate the @request.body.* filter resolver context. Your handler has just finished draining the body twice (once with io.ReadAll, once when you handed e.Request to protocol.ParseCredentialRequestResponse). The third read returns io.EOF, propagates through RequestInfo() → recordAuthResponse, and surfaces as error: "EOF" in the PB request log with a generic 89-byte {"data":{},"message":"Something went wrong while processing your request.","status":400} envelope to the client.
Fix: re-wrap before the call.
e.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
return apis.RecordAuthResponse(e, userRecord, "passkey", nil)
The pattern of “drain once, restore as needed” applies anywhere downstream code paths call e.RequestInfo() — which includes most PB apis.* helpers and any custom hook that uses @request.* rule resolution. If you find yourself doing this in more than one handler, hide it behind a small helper:
func withBufferedBody(e *core.RequestEvent, fn func(body []byte) error) error {
body, err := io.ReadAll(e.Request.Body)
if err != nil {
return err
}
// Always present a fresh reader to whoever asks next.
e.Request.Body = io.NopCloser(bytes.NewReader(body))
if err := fn(body); err != nil {
return err
}
e.Request.Body = io.NopCloser(bytes.NewReader(body))
return nil
}
Why this hides for so long in dev: the failure only surfaces on a successful login that gets all the way to RecordAuthResponse. Any earlier failure (parse error, unknown credential, validation error) returns before that point and never trips the bug. So local testing with broken/dummy bodies looks healthy, and the moment a real user with a real registered passkey actually completes the ceremony, every login fails with a generic envelope. Don’t rely on local “happy-path looked fine” — write an integration test that runs the full register → login round-trip against a real authenticator (or a virtual one like virtual-webauthn) before calling it shipped.
2. Discoverable vs Email-Based Sessions
The session key must match between options and validation:
- Email-based: session keyed by
userId— we already know who’s logging in. - Discoverable: session keyed by
"discoverable:" + session.Challenge. The challenge travels inside the signed credential response (CollectedClientData.Challenge), so the server can re-derive the key on verify without any extra round-trip state, extra header, or frontend coordination.
The first version of this code used a single static "discoverable" key for every in-flight discoverable login. That looked fine in single-user testing, but as soon as two passkey logins overlapped — two browser tabs, two users, or a retry after the user dismissed the first prompt — the second call overwrote the first session, and whoever completed the browser prompt first got a challenge-mismatch validation failure. It read as a generic “Authentication failed” from the client side, which made it nearly impossible to diagnose from frontend logs alone. Per-challenge keys fix this cleanly: every in-flight ceremony has its own slot, and validation looks up the exact one that belongs to the signed response.
An intermediate approach we considered was generating a random opaque sessionKey, returning it in the login-options response, and requiring the client to pass it back in a header. That works, but it’s strictly extra complexity — the challenge already does the job.
3. Full Credential Storage
Initially, we stored individual fields (public_key, transports). This caused “BackupEligible flag inconsistency” errors because flags weren’t preserved.
Solution: store the entire webauthn.Credential as JSON in a credentials field.
4. Go Migrations
Migrations are compiled Go files in pb_migrations/, registered via a blank import in main.go:
_ "github.com/keesfluitman/knowmore/pb/pb_migrations"
PocketBase auto-generates them when Automigrate: true is set in the migration command config. Schema changes made in the PocketBase admin UI produce Go migration files automatically.
5. Never Swallow the Counter Update
The WebAuthn spec asks the relying party to track an authenticator’s signature counter and treat a non-increasing value as a clone-detection signal. That means the save that writes the new counter after a successful login is genuinely load-bearing — but because it runs after validation has succeeded, it’s tempting to discard its error and return the auth token anyway.
The original code did exactly that (e.App.Save(passkeyRecord) with no error handling). When the save silently failed — usually because another login for the same credential landed in the same tick and raced on the row — the counter never advanced. The next login with that credential would then compare the incoming counter to a stale stored value, sometimes looking like a counter regression, sometimes matching exactly. Either outcome is wrong: a regression trips clone detection and blocks a legitimate user; an exact match defeats the detection that counter tracking exists to provide.
The fix is small but important: check the error, log it (including passkey id and user id for grep-ability), and still return the auth token. The login itself was valid, so the user shouldn’t be penalised — but operators need a trail to follow if clone-detection false positives start appearing later:
if err := e.App.Save(passkeyRecord); err != nil {
log.Printf("[webauthn] counter update failed (passkey=%s user=%s): %v",
passkeyRecord.Id, userId, err)
}
Pair this with the fake-options fix in the same commit: generateFakeOptions used to return a hardcoded 32-byte string so the response for non-existent users was byte-identical every time — trivially distinguishable from real responses and therefore a user-enumeration oracle. Swap in protocol.CreateChallenge() (or any crypto/rand-backed 32-byte value) and the fake path becomes indistinguishable from a real challenge.
Conclusion
Implementing WebAuthn with a custom Go backend for PocketBase provides enterprise-grade passwordless authentication while maintaining PocketBase’s simplicity. The key architectural decisions:
- Custom Go binary: Compiles WebAuthn logic directly into PocketBase alongside other Go plugins (notifications, web push)
- Full credential storage: JSON field preserves all WebAuthn flags
- Dual login modes: Support both email-based and discoverable login
- Bundled Docker deployment: Single container serves both the SPA and API
- Automatic domain config: Reads from PocketBase settings
The result is a privacy-focused, self-hosted authentication system that’s as convenient as any commercial SSO provider.
This is the fourth post in the KnowMore series. For the architecture overview, see Building KnowMore - Architecture of a Privacy-First Sports Calendar. For the real-time data system, see KnowMore Real-Time Data Loading.