auth.isdocs
Features

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.

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 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: preferred and userVerification: 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.

Next steps

Passkeys · auth.is docs