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.
- Go + Echo v4 — HTTP server
- go-webauthn/webauthn v0.15 — WebAuthn library
- MongoDB — users, credentials, challenges (TTL index for challenge cleanup)
- HTMX — page navigation
- @simplewebauthn/browser — ~50 lines of JS for the WebAuthn ceremony
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
- Passkey as a second factor alongside existing password auth
- Registration gated behind active JWT session (prevents pre-registration attacks)
- Per-credential labels (“iPhone Face ID”, “YubiKey”) for user management UI
- Redis for challenge storage (current app is multi-replica)
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.