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_url | RP id |
|---|---|
https://anvil.richardscollin.com | anvil.richardscollin.com |
https://anvil.localhost | anvil.localhost |
http://localhost:3000 | localhost |
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.