Passkeys
auth.is uses passkeys as a second factor, not as a passwordless replacement for the first one. A user always authenticates first — with their password or a "Continue with …" button — and then, on a client that opts in, a registered passkey is required as a second step. There is no passkey-primary login path in phase 1: a passkey strengthens a login, it does not start one.
A passkey is a WebAuthn credential (@simplewebauthn/server under the hood). Its relying-party id is the issuer host (acme.auth.is), so a passkey is bound to one issuer and can never be asserted against another — WebAuthn-level tenant isolation, the same boundary as everything else in auth.is.
Turning passkeys on for a client
Passkeys are a per-client capability. Set a client's passkeys_enabled with toggle_client_passkeys; new clients inherit the issuer's passkeysDefaultEnabled setting. Only a client with passkeys_enabled ever triggers the second-factor prompt or the enrollment offer — a client without it behaves as pure single-factor login.
Enabling passkeys on a client changes behaviour only for accounts that actually hold a passkey (second factor) or are eligible to be offered one (enrollment). A brand-new user with passkeys_enabled sees the offer after their first login, not a locked door.
Two moments: enrollment and second factor
The custom interaction policy is login → second_factor → consent → passkey_enroll. Two of those stages concern passkeys, and they never both fire for the same login:
Second factor
If the client has passkeys_enabled and the account already holds a passkey, the second_factor prompt fires after a fresh login and the user must assert their passkey to continue. It re-arms on every real authentication (it keys off this interaction's fresh login), but never on bare SSO session reuse — so silently reusing an existing issuer session does not re-challenge, while an actual re-login does.
Enrollment offer
If the client has passkeys_enabled, the account holds no passkey, and the offer is not currently snoozed, a post-login passkey_enroll nudge appears: "Set up a passkey?". It is an offer, never a gate — both "Set up" and "Not now" complete the login, so it can never dead-end. Choosing "Not now" snoozes the offer.
The snooze window defaults to 14 days and is controlled by the PASSKEY_OFFER_SNOOZE_DAYS environment variable on the IDP. After a user dismisses the offer, auth.is records the timestamp and stays quiet until the window elapses — so a user is nudged periodically, not on every login.
The WebAuthn ceremonies
Both round-trips run against the issuer-scoped passkey store, with the RP id set to the issuer host and the origin to https://{host}. Challenges are not held server-side globally — they are stashed on the short-lived interaction and passed back on verify.
- Registration (enrollment): the browser gets creation options with
residentKey: preferredanduserVerification: required; the user's existing credentials are excluded so the same authenticator cannot enrol twice. On verify, the attestation is checked (user verification required) and the credential is persisted. - Authentication (second factor): the browser gets request options listing the user's own credentials; the assertion is verified, bound to the authenticated account (a credential can only second-factor its own user), and the credential's signature counter is advanced.
A failed enrollment verification is non-blocking — the login simply continues without a passkey, and the offer can appear again after the snooze.