Skip to content
AXOWLDevelopers
Quickstart

Passkey login

Org-member (dashboard) passkey endpoints live under /api/passkey. On register/login a web origin gets an HttpOnly cookie (SDK callers keep the token from the response body).

Your app’s end users have their own set under /api/public/apps/{clientKey}/auth/passkey: POST /register-options, /register, /login-options and /login. Adding a passkey to an existing end-user account requires that user’s Authorization: Bearer token on /register-options; without it the call answers 409 LOGIN_REQUIRED_TO_ADD_PASSKEY.

One credential per account (stable user handle)

Section titled “One credential per account (stable user handle)”

Every enrollment endpoint sets the WebAuthn user.id (userHandle) to a deterministic value derived from the normalized email, not a random or account-row id. Platform authenticators (Windows Hello, Touch ID) de-duplicate resident credentials by (rpId, userHandle), so re-registering the same email — including after a database reset — replaces the existing passkey instead of piling up a new one. This keeps the authenticator’s credential set at one per account, which is what keeps the sign-in dialog fast (it has a single credential to enumerate, not dozens).

The userHandle is not used for identity: login verification always resolves the account from the returned credentialId against the database, never from the handle.

Method · RoutePurpose
POST /register/optionsAttestation options (Username = email, optional SecurityMode, turnstileToken when bot protection is on)
POST /registerComplete registration → auto-login (cookie issued)
Method · RoutePurpose
POST /add/optionsEnrollment options (authenticated). The user’s existing passkeys are listed in excludeCredentials, so an authenticator that already holds one refuses to create a duplicate
POST /addAdd a passkey to the current user (optional orgSlug)
Method · RoutePurpose
POST /login/optionsAssertion options (optional Username = email)
POST /loginComplete assertion → cookie / token

GET /exists?email= (does the user exist / has passkey / login provider; limited to 60 requests per minute per IP) and GET /browser-match?fingerprint= (authenticated) round out the set.