Skip to content

Passkeys (WebAuthn)

Passkeys work as a first factor (passwordless sign-in), a second factor (step-up), or both. The whole verification path is implemented on WebCrypto — CBOR parsing, COSE key conversion, ES256 signature checks, and X.509 chain validation — with no node:crypto anywhere near it.

Enrolling a passkey binds a credential to an account, so both endpoints require an authenticated session. The subject comes from the session cookie, never from the request body — otherwise anyone could enroll their own authenticator against someone else’s user id and take the account over.

POST /webauthn/register/options
Cookie: <tenant session>

Returns the PublicKeyCredentialCreationOptions to hand to navigator.credentials.create().

POST /webauthn/register/verify
Cookie: <tenant session>
{ "credential": { /* the browser's attestation response */ } }
{ "verified": true, "credentialId": "" }

A credential id may exist at most once, ever. Re-registering one that already belongs to any user — including the same user — is rejected, and a plain insert backstops the check against concurrent registrations.

Writes a passkey_registered audit event.

POST /webauthn/authenticate/options
{ "user_id": "usr_01J…" }
POST /webauthn/authenticate/verify
{ "credential": { /* the browser's assertion */ }, "flow": "<flow id>" }

Passing flow lets a passkey complete a pending /authorize flow — that is what makes passwordless sign-in and step-up work through the same ceremony.

Writes a passkey_authenticated audit event.

Omit user_id at /authenticate/options and you get a challenge with an empty allowCredentials list. The authenticator then chooses which credential to present, and the subject is resolved from the credential’s userHandle at verify time.

This is the flow behind a sign-in screen with no username field at all: the user taps their authenticator and is signed in.

fmt: "none" is accepted. Packed attestation is cryptographically verified in both forms:

  • Self-attestation — the signature is checked against the credential’s own key.
  • x5c — the leaf certificate’s signature over authData ‖ clientDataHash is verified, the certificate chain is validated for links and validity windows, and the chain can be anchored to configured trust roots.

Unknown attestation formats are rejected rather than accepted unverified.

An organization can restrict which authenticator models are acceptable, by AAGUID:

PUT /v1/organizations/org_01J…/webauthn_policy
Authorization: Bearer sk_live_…
{ "aaguid_allowlist": ["d8522d9f-…", ""] }

Registration then rejects any authenticator whose AAGUID is not on the list. This is how a customer requiring specific certified hardware keys enforces it, rather than trusting that users chose the right device.

aaguid_allowlist is required in the body and must be an array of strings or null. Pass null to clear the policy and accept any authenticator again. Writes a webauthn_policy_updated audit event.

Authenticator sign counts are stored and checked on each authentication. A counter that fails to advance — the classic signal of a cloned authenticator — fails the authentication with possible cloned authenticator (signCount regressed), rather than being recorded and waved through.

Authenticators that report a constant zero counter (many platform authenticators do) are exempt, as the specification allows.

The RP id is the issuer hostname, so passkeys registered on acme.oauth.work are scoped to that host. This follows from tenant resolution by host: a passkey belongs to the tenant the user registered it on.

PasskeysTOTP
Phishing-resistantYes — bound to the originNo
Works as a first factorYesNo
Needs a compatible deviceYesAny authenticator app
RecoveryRegister more than oneRecovery codes

Passkeys are the stronger option, and being origin-bound is why. Offer TOTP alongside them for users whose devices or policies do not allow passkeys.