Before you start
- A Go HTTP server that uses a chi router, or can mount one.
- Users who can already sign in another way, because registering a passkey needs a signed in session.
- HTTPS in production. Browsers only allow WebAuthn on an HTTPS origin or on
localhost.
How the pieces fit
Each ceremony is two calls. The server hands out a single use challenge, the browser asks the authenticator to sign it, and the server verifies the result. Registration runs for a user who is already signed in and stores the new credential. Sign in is discoverable: the browser offers the passkeys it has for your site, so the user does not type a name first, and a verified assertion starts a session.
Turn it on in the server
Step 1Add the module
theAuth runs inside your process and keeps users, sessions and passkeys in your own database.
bash go get github.com/glincker/theauth-go/v2Step 2Configure the relying party
Set
Config.WebAuthn.RPIDis your registrable domain with no scheme and no port, andRPOriginslists the exact origins the browser uses. Every built in store (memory, SQLite, Postgres and MySQL) can hold passkeys; use memory only to try it out.auth.go a, err := theauth.New(theauth.Config{ Storage: store, // SQLite, Postgres or MySQL for real data BaseURL: "https://myapp.com", WebAuthn: &theauth.WebAuthnConfig{ RPID: "myapp.com", RPDisplayName: "My App", RPOrigins: []string{"https://myapp.com"}, }, }) if err != nil { log.Fatal(err) } defer a.Close()Step 3Mount the routes
Mountadds the passkey routes under/auth/webauthn:register/beginandregister/finishfor a signed in user,login/beginandlogin/finishfor anyone, each rate limited per client address.go r := chi.NewRouter() a.Mount(r) log.Fatal(http.ListenAndServe(":8080", r))
Wire it in the browser
Step 4Add two base64url helpers
The begin endpoints return challenges and ids as base64url text, and the browser API wants binary buffers, so convert both ways.
passkeys.js // WebAuthn sends binary fields; the JSON endpoints use base64url. function b64uToBuf(s) { const pad = "=".repeat((4 - (s.length % 4)) % 4); const bin = atob((s + pad).replace(/-/g, "+").replace(/_/g, "/")); return Uint8Array.from(bin, (c) => c.charCodeAt(0)).buffer; } function bufToB64u(buf) { let s = ""; for (const b of new Uint8Array(buf)) s += String.fromCharCode(b); return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); }Step 5Register a passkey for the signed in user
Call this from an account page. The browser shows its own prompt, and the
namequery parameter labels the passkey so the user can tell their devices apart later.js async function registerPasskey(name) { const opt = await (await fetch("/auth/webauthn/register/begin", { method: "POST" })).json(); const pk = opt.publicKey; pk.challenge = b64uToBuf(pk.challenge); pk.user.id = b64uToBuf(pk.user.id); pk.excludeCredentials?.forEach((c) => (c.id = b64uToBuf(c.id))); const cred = await navigator.credentials.create({ publicKey: pk }); const res = await fetch(`/auth/webauthn/register/finish?name=${encodeURIComponent(name)}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: cred.id, rawId: bufToB64u(cred.rawId), type: cred.type, response: { attestationObject: bufToB64u(cred.response.attestationObject), clientDataJSON: bufToB64u(cred.response.clientDataJSON), }, }), }); if (!res.ok) throw new Error(`register failed: ${res.status}`); }Step 6Sign in with a passkey
No user name is needed. The browser lists the passkeys it holds for this site, and a verified assertion starts a session the same way a password sign in does.
js async function signInWithPasskey() { const opt = await (await fetch("/auth/webauthn/login/begin", { method: "POST" })).json(); const pk = opt.publicKey; pk.challenge = b64uToBuf(pk.challenge); pk.allowCredentials?.forEach((c) => (c.id = b64uToBuf(c.id))); const cred = await navigator.credentials.get({ publicKey: pk }); const r = cred.response; const res = await fetch("/auth/webauthn/login/finish", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: cred.id, rawId: bufToB64u(cred.rawId), type: cred.type, response: { authenticatorData: bufToB64u(r.authenticatorData), clientDataJSON: bufToB64u(r.clientDataJSON), signature: bufToB64u(r.signature), userHandle: r.userHandle ? bufToB64u(r.userHandle) : null, }, }), }); if (!res.ok) throw new Error(`sign in failed: ${res.status}`); }
Manage and harden
Step 7Let users list, rename and remove passkeys
Three routes need a signed in session: list the user's passkeys, rename one, and delete one. Rename answers
501when the storage backend does not support it.js const list = await (await fetch("/auth/webauthn/credentials")).json(); await fetch(`/auth/webauthn/credentials/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Work laptop" }), }); await fetch(`/auth/webauthn/credentials/${id}`, { method: "DELETE" });Step 8Require a PIN or biometric
By default user verification is preferred, not required. Set
RequireUserVerificationso a passkey always comes with a PIN or biometric check and is never a bare possession factor.go WebAuthn: &theauth.WebAuthnConfig{ RPID: "myapp.com", RPDisplayName: "My App", RPOrigins: []string{"https://myapp.com"}, RequireUserVerification: true, },