Skip to content

WebAuthn Passkey

Sia edited this page May 31, 2026 · 3 revisions

WebAuthn (Passkey) 2FA

A phishing-resistant alternative to TOTP for two-factor authentication. Built on webauthn4j 0.29.1.

Why passkeys

Threat TOTP Passkey
Phishing site collects your code ⚠️ vulnerable ✅ safe (same-origin signature)
Lost shared secret on server breach ⚠️ replayable ✅ public key only
Lost backup codes / device ⚠️ recovery flow ⚠️ recovery flow (register multiple)
Cross-device convenience ⚠️ manual scan ✅ iCloud / Google Password Manager sync

Both stay available concurrently — pick whichever you prefer at login.

Quick start

  1. Log in with username + password (TOTP if enabled).
  2. Open /webauthn (nav: "Passkey (WebAuthn)").
  3. Type a name ("MacBook Touch ID", "YubiKey 5C", etc.) and click 이 디바이스에서 passkey 등록.
  4. Complete the platform prompt (Touch ID / Windows Hello / FIDO2 key).
  5. Page reloads showing your new passkey.

On the next login, click 🔑 Passkey 로 로그인 after typing your username — the browser locates the matching credential and signs the server's challenge. No password needed.

Configuration

# server.yml
server:
  host: 0.0.0.0
  port: 17880
  # ...
webauthn:
  rpId:   "vibe.local"                    # bare hostname users type
  rpName: "Vibe Coder"
  origin: "http://vibe.local:17880"       # full origin, must match exactly

rpId is the relying-party identifier — a bare hostname (no scheme, no port). It must be a registrable suffix of origin's host. The browser refuses to register or sign for an origin that doesn't match.

LAN example: users browse to http://vibe.local:17880, so rpId: "vibe.local" and origin: "http://vibe.local:17880".

External HTTPS example: rpId: "vibe.example.com", origin: "https://vibe.example.com".

⚠️ If you register passkeys against rpId: localhost and later change the URL users type, those passkeys stop working. Plan the hostname before relying on passkeys.

Storage

webauthn_credentials table (PostgreSQL):

Column Notes
id UUID (server-side row id)
user_id references admin_users.id
credential_id base64url-encoded; UNIQUE
attestation_object base64url-encoded CBOR; rebuilds CredentialRecord on assertion
sign_count bumped on every successful assertion (cloning detection)
transports comma-joined hint list (usb,nfc,hybrid, etc.)
attestation_type informational (none, self, packed, fido-u2f)
name user-friendly label
created_at / last_used_at ISO-8601

The full attestation object (not just the public key) is persisted so the 4-arg CredentialRecordImpl(AttestationObject, ...) constructor can rebuild the record during assertion verification.

Registration flow (browser ↔ server)

Browser                                Server
───────                                ──────
POST /api/webauthn/register/options   →  beginRegistration(userId)
                                          • generate 32-byte challenge
                                          • cache for 5 min in memory
                  ←                       { challenge, rpId, rpName,
                                            userId, username,
                                            excludeCredentialIds }

navigator.credentials.create({
  publicKey: {
    challenge, rp:{id,name}, user,
    pubKeyCredParams: [{type:'public-key', alg:-7}, {alg:-257}],
    excludeCredentials,
    authenticatorSelection: { residentKey:'preferred',
                              userVerification:'preferred' },
    attestation: 'none'
  }
})  → user prompt → AuthenticatorAttestationResponse

POST /api/webauthn/register/verify    →  finishRegistration(userId, ...)
{ clientDataJSON,                         • webauthn4j.validate(...)
  attestationObject,                      • persist row
  transports, name }                      • audit log
                  ←                       { id, name, ok:true }

Assertion flow (login)

The assertion path is unauthenticated — it's the login itself.

Browser                                Server
───────                                ──────
POST /api/webauthn/assert/options     →  beginAssertion(usernameHint)
{ username }                              • lookup user → list credentials
                  ←                       { challenge, rpId,
                                            allowCredentialIds }

navigator.credentials.get({
  publicKey: {
    challenge, rpId,
    allowCredentials: [...],
    userVerification: 'preferred'
  }
})  → user prompt → AuthenticatorAssertionResponse

POST /api/webauthn/assert/verify      →  finishAssertion(...)
{ credentialId,                           • parse clientDataJSON.challenge
  authenticatorData,                      • match in-memory challenge entry
  clientDataJSON,                         • rebuild CredentialRecord from
  signature,                                stored attestationObject
  userHandle }                            • webauthn4j.validate(...)
                                          • update sign_count + last_used_at
                                          • mint new device row + token
                  ←                       Set-Cookie: vibe_session=...
                                          { token, deviceId, username }

If the username doesn't exist, allowCredentialIds comes back empty — timing-safe with credential discovery.

Audit

Action Logged as
Register passkey auth.passkey.register OK
Login via passkey auth.passkey.login OK
Delete passkey auth.passkey.delete OK

See Audit Log for filtering recipes.

Passwordless-only mode

The admin user has a passwordless_only flag in admin_users. When on AND the user has at least one registered passkey, the server rejects password / TOTP login with 401 passkey_required — passkey is the only allowed path.

  • Toggle from /webauthn page (the second card). Activating with zero passkeys is refused (lockout protection).
  • Deactivation is always allowed — recovery path when an authenticator is lost. If passwordless_only is on AND every passkey is also lost, an admin must edit the DB directly (UPDATE admin_users SET passwordless_only = false WHERE id = ...;) — there is no built-in recovery flow yet.
  • The flag participates in AuthService.login(... hasPasskey) — caller passes webauthn.hasCredentials(userId) so the check is keyed on the current registry, not a stale schema bit.
  • Audit log: same auth.passkey.login for successful passkey logins; failed password attempts log passkey_required as the reason.

Known limitations

  • Challenges live in memory. Restarting the server cancels any registration / assertion currently in flight. Single-user dev server context — restart is rare.
  • rpId is hostname-strict. Move your hostname and existing passkeys stop working — register fresh ones on the new origin.

Related

Clone this wiki locally