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.
Registration
Section titled “Registration”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/optionsCookie: <tenant session>Returns the PublicKeyCredentialCreationOptions to hand to navigator.credentials.create().
POST /webauthn/register/verifyCookie: <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.
Authentication
Section titled “Authentication”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.
Discoverable (usernameless) credentials
Section titled “Discoverable (usernameless) credentials”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.
Attestation
Section titled “Attestation”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 overauthData ‖ clientDataHashis 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.
Authenticator policy
Section titled “Authenticator policy”An organization can restrict which authenticator models are acceptable, by AAGUID:
PUT /v1/organizations/org_01J…/webauthn_policyAuthorization: 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.
Sign counters
Section titled “Sign counters”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.
Relying party id
Section titled “Relying party id”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.
Choosing passkeys or TOTP
Section titled “Choosing passkeys or TOTP”| Passkeys | TOTP | |
|---|---|---|
| Phishing-resistant | Yes — bound to the origin | No |
| Works as a first factor | Yes | No |
| Needs a compatible device | Yes | Any authenticator app |
| Recovery | Register more than one | Recovery 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.