anvilsign in

collin/anvil

RenderedSource

Passkeys

Sign in with Touch ID, Windows Hello, a phone, or a security key instead of an account password. Passkeys are for login only — repository secrets (secrets.md) stay keyed to your ssh keys, because CI needs to unlock them from a terminal where no authenticator is present.

Using them

Register (account settings → Passkeys): name the device, press Add passkey, approve the prompt. Registering a second passkey on the same authenticator is refused by the browser rather than silently duplicated — anvil sends the existing credential ids as excludeCredentials.

Sign in: the login page's Sign in with a passkey button. No username: a passkey is a discoverable credential, so the authenticator tells anvil which credential it used and that identifies the account.

Password sign-in keeps working, and remains the way in if you lose every authenticator. Removing your last passkey is allowed for the same reason.

What anvil stores, and what it means if the database leaks

Only public material: the credential id, the credential's public key, and the counters WebAuthn asks a relying party to track. The private key stays in the authenticator and is never transmitted, so — unlike a password hash — nothing in the passkeys table can be turned into a login, offline or otherwise. A leak costs users their registrations, not their accounts.

Two properties come from the protocol rather than from anvil's code:

  • Phishing resistance. The authenticator binds every signature to anvil's relying-party id. A look-alike site cannot get a usable signature, even with a perfect replica of this UI.
  • Replay resistance. Every ceremony is a fresh random challenge, held in memory, valid for five minutes, and accepted exactly once.

The relying-party id is your base_url host

WebAuthn scopes a credential to one host, taken here from http.base_url:

base_urlRP id
https://anvil.richardscollin.comanvil.richardscollin.com
https://anvil.localhostanvil.localhost
http://localhost:3000localhost

Consequences worth knowing before you move an instance:

  • Change the host and existing passkeys stop working. They are not deleted, they simply belong to a different site now; users re-register (password login is the way back in).
  • Passkeys do not travel between instances. One created against the local Docker instance (deploy/dev.sh) is not usable on production, by design.
  • WebAuthn requires a secure context: HTTPS, or plain localhost. A LAN IP over HTTP will not offer passkeys at all. deploy/dev.sh + portless gives local development real HTTPS, which is why passkeys can be tested there.

Implementation

crates/anvil-core/src/passkeys.rs holds the credential storage and the in-memory challenge registry; crates/anvil-web/src/passkeys.rs holds the two ceremonies, the JSON, and the browser glue.

Verification is webauthn_rp, chosen over the better-known webauthn-rs for one hard reason: webauthn-rs depends on OpenSSL, and anvil ships as a statically linked musl binary built by deploy/build.sh with no C toolchain in the picture. webauthn_rp is pure Rust and implements the spec's ceremony steps explicitly.

Only passkeys are supported — discoverable credentials with user verification required. No attestation is requested (none), which is the norm for consumer authenticators and avoids collecting hardware identifiers we have no use for.

The browser side hand-rolls the base64url ↔ ArrayBuffer conversions rather than using PublicKeyCredential.parseCreationOptionsFromJSON() / toJSON(): those are recent enough that relying on them would narrow support to new browsers for no gain.