Skip to main content
Passkeys are WebAuthn credentials: Touch ID, Windows Hello, hardware keys, or a password manager’s synced passkeys. They’re phishing-resistant (bound to the AroPay origin), involve no shared secret, and count as strong MFA on their own: a passkey login never asks for a TOTP code. That holds because AroPay requires user verification: the authenticator must check a PIN or biometric at registration and at every sign-in, so a passkey proves both that you hold the device and that you are you. Platform passkeys (Touch ID, Face ID, Windows Hello, password-manager passkeys) always do this. A hardware security key needs a PIN set; the browser offers to set one. A key that can’t verify the user fails with passkey_verification_failed.

Register a passkey

Registration is session-only and, in practice, a dashboard flow (Settings → Passkeys → Add passkey) since it needs a browser’s WebAuthn API. A session alone isn’t enough: you confirm with your password, plus a code from your authenticator app when 2FA is on (step-up).
1

Get registration options

POST /me/passkeys/options with { "password": "…", "code": "123456" } (code only when 2FA is on) checks the step-up, then returns WebAuthn creation options; the challenge rides in a signed cookie bound to your account, its token version and this session. Without step-up there is no challenge, so register can’t succeed, and asking for options again replaces the previous challenge.
2

Create the credential

The browser calls navigator.credentials.create() with those options and the authenticator does its ceremony.
3

Register it

POST /me/passkeys/register with the WebAuthn credential (and an optional name like “MacBook Touch ID”) verifies the attestation against the challenge cookie and stores the credential. Re-registering the same authenticator returns 409 passkey_exists. A challenge answers one attempt, even a failed one (400 invalid_challenge after that), so to retry, start again from step 1. The request must be application/json: anything else gets 415 unsupported_media_type before it touches the challenge, so a forged form post can’t cancel your enrollment.If your password is changed, 2FA is turned on, or an admin resets the account before you finish, the registration can’t complete: either that ended your session (401 session_expired), or this browser got a new session and the challenge belongs to the old one (400 invalid_challenge). Sign in if needed and start again from step 1. Only if the change lands while the register request itself is being processed do you get 409 account_changed, with nothing stored.

Sign in with a passkey

With email omitted, discoverable credentials let the authenticator pick the account: true one-tap, usernameless sign-in. Challenge handling is strict: options and verification must come from the same client (the challenge cookie is signed), challenges expire quickly (400 challenge_expired), and each is single-use (400 invalid_challenge). An assertion from an authenticator the account doesn’t know fails with 401 unknown_passkey.

Manage devices

Each entry shows id, deviceName, transports, createdAt, and lastUsedAt, enough to audit which devices can access the account and prune stale ones. Passkeys survive a password change unless you ask: tick Also remove all my passkeys (removePasskeys: true on POST /me/password) if someone else may have had access to your account. When an admin resets your password or 2FA, or reactivates the account, they decide explicitly whether your passkeys stay (recovery).
Register at least two passkeys (e.g. laptop + phone) so losing one device never locks you out. Passwords keep working alongside passkeys, so you always have a fallback path; protect it with 2FA.