-
Notifications
You must be signed in to change notification settings - Fork 1
WebAuthn Passkey
Introduced in v0.48.0 as Phase 27. A phishing-resistant alternative
to TOTP for two-factor authentication. Built on
webauthn4j 0.29.1 — the only new server dep since v0.46.0.
| Threat | TOTP | Passkey |
|---|---|---|
| Phishing site collects your code | ✅ safe (same-origin signature) | |
| Lost shared secret on server breach | ✅ public key only | |
| Lost backup codes / device | ||
| Cross-device convenience | ✅ iCloud / Google Password Manager sync |
Both stay available concurrently — pick whichever you prefer at login.
- Log in with username + password (TOTP if enabled).
- Open
/webauthn(nav: "Passkey (WebAuthn)"). - Type a name ("MacBook Touch ID", "YubiKey 5C", etc.) and click 이 디바이스에서 passkey 등록.
- Complete the platform prompt (Touch ID / Windows Hello / FIDO2 key).
- 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.
# server.yml
server:
host: 0.0.0.0
port: 17880
version: 0.48.0
# ...
webauthn:
rpId: "vibe.local" # bare hostname users type
rpName: "Vibe Coder"
origin: "http://vibe.local:17880" # full origin, must match exactlyrpId 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 againstrpId: localhostand later change the URL users type, those passkeys stop working. Plan the hostname before promoting passkeys to your team.
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.
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 }
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.
| 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.
Each 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
/webauthnpage (the second card). Activating on a user with zero passkeys is refused (lockout protection). - Deactivation is always allowed — recovery path when an authenticator
is lost. If
passwordless_onlyis 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 passeswebauthn.hasCredentials(userId)so the check is keyed on the current registry, not a stale schema bit. - Audit log: same
auth.passkey.loginfor successful passkey logins; failed password attempts logpasskey_requiredas the reason.
- Challenges live in memory. Restarting the server cancels any registration / assertion currently in flight. Single-user dev server context — restart is rare.
- No "passkey-only" enforcement. A user with a passkey can still log in with password (+ TOTP if enabled). An optional toggle to refuse password-based login for users with at least one passkey is planned for a future minor.
- rpId is hostname-strict. Move your hostname and existing passkeys stop working — register fresh ones on the new origin.
- Two-Factor Auth (TOTP) — the sister method, available concurrently.
- Security Model — the full auth threat model.
- WebAuthn spec: https://www.w3.org/TR/webauthn-3/
- webauthn4j: https://github.com/webauthn4j/webauthn4j