Skip to content

Adding PassKey Auth to Production — My Go Demo First

Published: at 04:00 PMSuggest Changes

Why

My production app uses username/password auth. Passwords are a liability — breached credentials, phishing, user friction. PassKey eliminates all three.

Before touching prod, I built a demo to understand the WebAuthn ceremony hands-on.

What I Built

Single Go binary — backend + UI served together.

Source: github.com/ypo777/passkey-goland

How WebAuthn Works

Two phases, four endpoints:

Registration (link passkey to account)
  POST /passkey/register/begin   → server generates challenge
  POST /passkey/register/finish  → browser signs + sends credential → stored in MongoDB

Login (prove identity without password)
  POST /passkey/login/begin      → server generates challenge + allowed credentials
  POST /passkey/login/finish     → browser signs → server verifies → JWT issued

Browser handles biometric/PIN prompt. Server never sees the private key — only the public key is stored.

Key Things I Learned

1. Challenge storage matters

Challenges are one-time-use, short-lived (120s). I used MongoDB with a TTL index — production-realistic and auto-cleans without cron.

First attempt I used ReplaceOne with upsert. Broke when a challenge doc already existed — MongoDB rejects replacing a document’s _id. Fixed with UpdateOne + $set/$setOnInsert.

2. Store credential flags

go-webauthn stores BackupEligible and BackupState flags on the credential at registration. On login it checks them against what’s stored. If you don’t persist these fields, every login fails with a flag inconsistency error. macOS iCloud Keychain sets BackupEligible: true — caught me off guard.

3. RPID is binding

RPID must equal the registrable domain (localhost for dev, example.com for prod). Credentials are cryptographically bound to it at registration — you can’t change it without invalidating all existing passkeys.

4. sign_count is your cloned-key detector

Every authentication increments sign_count. If incoming count ≤ stored count, the authenticator was cloned or the assertion was replayed. Always check and update it.

5. HTMX + ~50 lines of JS

HTMX handles all navigation and UI swaps. Only the WebAuthn ceremony (4 fetch calls + browser API) needs raw JS — navigator.credentials.create() and navigator.credentials.get() can’t be replaced by HTMX.

What’s Next for Production

Takeaway

The WebAuthn spec looks dense but the Go library handles the hard parts. The real work is storage design and flag handling — both of which the spec is strict about for good reason.

If you’re on Go + MongoDB, the demo is a working starting point.


Next Post
Automating My macOS Tahoe Setup with Ansible