Passkey / WebAuthn API
The Passkey / WebAuthn API exposes FIDO2-compliant registration and authentication flows and provides admin-level credential management. All WebAuthn ceremony endpoints follow the W3C WebAuthn Level 2 specification and are compatible with platform authenticators (Face ID, Touch ID, Windows Hello) and roaming authenticators (FIDO2 security keys).
Overview
Section titled “Overview”WebAuthn authentication is a two-step ceremony. Each step consists of a begin request that returns a challenge options object and a complete request that submits the authenticator’s signed response.
Registration ceremony — enroll a new passkey for a user:
POST /api/auth/passkey/register/begin— server generates a challenge and returnsPublicKeyCredentialCreationOptionsPOST /api/auth/passkey/register/complete— client submits the signed attestation; server stores the credential
Authentication ceremony — sign in with an existing passkey:
POST /api/auth/passkey/authenticate/begin— server generates a challenge and returnsPublicKeyCredentialRequestOptionsPOST /api/auth/passkey/authenticate/complete— client submits the signed assertion; server returns a session token
Authentication
Section titled “Authentication”Admin endpoints (/api/v1/admin/passkeys/) require an admin-scoped API key:
Authorization: Bearer arb_live_your-api-key-hereSelf-service endpoints (/api/auth/passkey/) require an authenticated user session. Pass the session token returned from a prior authentication:
Authorization: Bearer arb_session_...Requests with missing or invalid credentials return 401 Unauthorized. Requests with valid credentials that lack the required scope return 403 Forbidden.
Endpoint summary
Section titled “Endpoint summary”| Method | Path | Description |
|---|---|---|
POST |
/api/auth/passkey/register/begin |
Start passkey registration |
POST |
/api/auth/passkey/register/complete |
Complete passkey registration |
POST |
/api/auth/passkey/authenticate/begin |
Start passkey authentication |
POST |
/api/auth/passkey/authenticate/complete |
Complete passkey authentication |
GET |
/api/auth/passkey/credentials |
List my passkeys (self-service) |
DELETE |
/api/auth/passkey/credentials/{credential_id} |
Remove own passkey (self-service) |
GET |
/api/v1/admin/passkeys/ |
List all passkeys (admin) |
GET |
/api/v1/admin/passkeys/{credential_id} |
Get passkey detail (admin) |
DELETE |
/api/v1/admin/passkeys/{credential_id} |
Revoke a passkey (admin) |
Registration flow
Section titled “Registration flow”Start passkey registration
Section titled “Start passkey registration”POST /api/auth/passkey/register/beginGenerates a registration challenge. The response is a standard PublicKeyCredentialCreationOptions object that must be passed directly to navigator.credentials.create() in the browser or the platform authenticator SDK.
When called by an admin on behalf of another user, include user_id in the request body. When called in a self-service context, omit the body or send an empty object — the server resolves the user from the session.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
user_id |
string (UUID) | No | Target user ID. Admin use only. Omit for self-service registration. |
Request
curl -X POST "https://api.arbitex.ai/api/auth/passkey/register/begin" \ -H "Authorization: Bearer arb_live_your-api-key-here" \ -H "Content-Type: application/json" \ -d '{ "user_id": "usr_01HZ_ALICE" }'Response 200 OK
{ "challenge": "dGhpcyBpcyBhIHNhbXBsZSBjaGFsbGVuZ2U", "rp": { "name": "Arbitex", "id": "arbitex.ai" }, "user": { "id": "dXNyXzAxSFpfQUxJQ0U", "displayName": "Alice Chen" }, "pubKeyCredParams": [ { "type": "public-key", "alg": -7 }, { "type": "public-key", "alg": -257 } ], "authenticatorSelection": { "authenticatorAttachment": "platform", "residentKey": "required", "userVerification": "required" }, "timeout": 60000, "attestation": "none"}| Field | Type | Description |
|---|---|---|
challenge |
string (base64url) | Random challenge that the authenticator must sign |
rp |
object | Relying party — the application’s identity |
rp.id |
string | Relying party origin (must match the request origin) |
user |
object | User handle and display information |
pubKeyCredParams |
array | Acceptable public key credential algorithms |
authenticatorSelection |
object | Constraints on the authenticator type and user verification |
timeout |
integer | Maximum milliseconds the browser will wait for the authenticator |
Error responses
| Status | Description |
|---|---|
404 |
user_id not found (admin call only) |
409 |
User has already registered the maximum number of passkeys |
Complete passkey registration
Section titled “Complete passkey registration”POST /api/auth/passkey/register/completeSubmits the attestation object produced by the authenticator to complete registration. The server verifies the signature against the challenge issued by register/begin and stores the credential.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
credential |
object | Yes | The PublicKeyCredential object returned by navigator.credentials.create() |
credential.id |
string (base64url) | Yes | Credential identifier |
credential.rawId |
string (base64url) | Yes | Raw credential identifier bytes |
credential.response.attestationObject |
string (base64url) | Yes | CBOR-encoded attestation object |
credential.response.clientDataJSON |
string (base64url) | Yes | Client data JSON |
credential.type |
string | Yes | Must be "public-key" |
Request
curl -X POST "https://api.arbitex.ai/api/auth/passkey/register/complete" \ -H "Authorization: Bearer arb_live_your-api-key-here" \ -H "Content-Type: application/json" \ -d '{ "credential": { "id": "pQvKbQ9X2m4YrZ8fNcOwJA", "rawId": "pQvKbQ9X2m4YrZ8fNcOwJA", "response": { "attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVik...", "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiZEdocGN5QnBjeUJoSUhOaGJYQmxaQ0JqYUdGc2JHVW5iUSIsIm9yaWdpbiI6Imh0dHBzOi8vYXJiaXRleC5haSJ9" }, "type": "public-key" } }'Response 201 Created
{ "credential_id": "a3f8e2b1-4c7d-4e9a-8b6f-1d2e3f4a5b6c", "created_at": "2026-03-14T10:00:00Z", "aaguid": "adce0002-35bc-c60a-648b-0b25f1f05503", "transports": ["internal", "hybrid"]}| Field | Type | Description |
|---|---|---|
credential_id |
string (UUID) | Arbitex-assigned credential identifier |
created_at |
datetime | ISO 8601 registration timestamp |
aaguid |
string (UUID) | Authenticator Attestation GUID identifying the authenticator model |
transports |
array of string | Reported transport hints: internal, usb, nfc, ble, hybrid |
Error responses
| Status | Description |
|---|---|
400 |
Challenge has expired (default TTL: 5 minutes) — restart the ceremony |
422 |
Attestation verification failed — invalid signature, origin mismatch, or malformed CBOR |
Authentication flow
Section titled “Authentication flow”Start passkey authentication
Section titled “Start passkey authentication”POST /api/auth/passkey/authenticate/beginGenerates an authentication challenge. The response is a standard PublicKeyCredentialRequestOptions object that must be passed to navigator.credentials.get().
For discoverable credentials (passkeys), omit user_id — the browser will prompt the user to select from their stored passkeys. For non-discoverable credentials (legacy security keys), supply user_id to populate allowCredentials with the user’s enrolled credential IDs.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
user_id |
string (UUID) | No | For non-discoverable flow only — populates allowCredentials. Omit for discoverable credentials. |
Request
# Discoverable (passkey) flow — no user_id requiredcurl -X POST "https://api.arbitex.ai/api/auth/passkey/authenticate/begin" \ -H "Content-Type: application/json" \ -d '{}'Response 200 OK
{ "challenge": "cmFuZG9tQ2hhbGxlbmdlQnl0ZXNIZXJl", "rpId": "arbitex.ai", "allowCredentials": [], "timeout": 60000, "userVerification": "required"}| Field | Type | Description |
|---|---|---|
challenge |
string (base64url) | Random challenge the authenticator must sign |
rpId |
string | Relying party ID — must match the registration rp.id |
allowCredentials |
array | Credential descriptors to limit which credentials may respond. Empty for discoverable flow. |
userVerification |
string | required — biometric or PIN verification is mandatory |
Error responses
| Status | Description |
|---|---|
404 |
user_id not found (non-discoverable flow only) |
422 |
user_id found but the user has no enrolled passkeys |
Complete passkey authentication
Section titled “Complete passkey authentication”POST /api/auth/passkey/authenticate/completeSubmits the signed assertion produced by the authenticator. The server verifies the signature, checks the sign counter for cloning detection, and returns a session token on success.
Request body — standard WebAuthn AuthenticatorAssertionResponse wrapped in a PublicKeyCredential
| Field | Type | Required | Description |
|---|---|---|---|
credential.id |
string (base64url) | Yes | Credential identifier returned by the authenticator |
credential.rawId |
string (base64url) | Yes | Raw credential identifier bytes |
credential.response.authenticatorData |
string (base64url) | Yes | Authenticator data |
credential.response.clientDataJSON |
string (base64url) | Yes | Client data JSON |
credential.response.signature |
string (base64url) | Yes | Assertion signature |
credential.response.userHandle |
string (base64url) | No | User handle (provided by discoverable credentials) |
credential.type |
string | Yes | Must be "public-key" |
Request
curl -X POST "https://api.arbitex.ai/api/auth/passkey/authenticate/complete" \ -H "Content-Type: application/json" \ -d '{ "credential": { "id": "pQvKbQ9X2m4YrZ8fNcOwJA", "rawId": "pQvKbQ9X2m4YrZ8fNcOwJA", "response": { "authenticatorData": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MFAAAABg", "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiY21GdVpHOXRRMmhoYkd4bGJtZEJlblJsY2lJLCJvcmlnaW4iOiJodHRwczovL2FyYml0ZXguYWkifQ", "signature": "MEUCIQDktp8yRBGGzLKxV3Q9JlGJXNNq5QjLtj3P5KYzXkR4IAIgFgNM9r..." }, "type": "public-key" } }'Response 200 OK
{ "token": "arb_session_eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9...", "user_id": "usr_01HZ_ALICE", "credential_id": "a3f8e2b1-4c7d-4e9a-8b6f-1d2e3f4a5b6c", "sign_count": 42}| Field | Type | Description |
|---|---|---|
token |
string | Session token for subsequent API requests |
user_id |
string | Authenticated user identifier |
credential_id |
string (UUID) | Arbitex credential identifier that was used |
sign_count |
integer | Updated authenticator signature counter — used to detect cloned credentials |
Error responses
| Status | Description |
|---|---|
400 |
Challenge has expired — restart the authentication ceremony |
401 |
Signature verification failed |
409 |
Sign count decreased — possible credential cloning detected; credential suspended pending admin review |
422 |
Malformed assertion data |
Self-service credential management
Section titled “Self-service credential management”List my passkeys
Section titled “List my passkeys”GET /api/auth/passkey/credentialsReturns all passkeys enrolled for the authenticated user. Requires a valid user session.
Request
curl "https://api.arbitex.ai/api/auth/passkey/credentials" \ -H "Authorization: Bearer arb_session_eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9..."Response 200 OK
[ { "credential_id": "a3f8e2b1-4c7d-4e9a-8b6f-1d2e3f4a5b6c", "aaguid": "adce0002-35bc-c60a-648b-0b25f1f05503", "transports": ["internal", "hybrid"], "created_at": "2026-03-14T10:00:00Z", "last_used_at": "2026-03-14T09:48:00Z", "sign_count": 42 }, { "credential_id": "b4e9f3c2-5d8e-4f0b-9c7g-2e3f4a5b6c7d", "aaguid": "f8a011f3-8c0a-4d15-8006-17111f9edc7d", "transports": ["usb"], "created_at": "2026-01-20T14:30:00Z", "last_used_at": "2026-03-10T11:20:00Z", "sign_count": 187 }]Remove own passkey
Section titled “Remove own passkey”DELETE /api/auth/passkey/credentials/{credential_id}Removes a passkey from the authenticated user’s account. The credential is immediately invalidated and cannot be used for future authentication.
Path parameters
| Parameter | Description |
|---|---|
credential_id |
UUID of the credential to remove |
Request
curl -X DELETE \ "https://api.arbitex.ai/api/auth/passkey/credentials/a3f8e2b1-4c7d-4e9a-8b6f-1d2e3f4a5b6c" \ -H "Authorization: Bearer arb_session_eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9..."Response 204 No Content
Error responses
| Status | Description |
|---|---|
403 |
Credential belongs to a different user |
404 |
Credential not found |
409 |
Cannot remove the last credential — user would be locked out |
Admin credential management
Section titled “Admin credential management”List all passkeys
Section titled “List all passkeys”GET /api/v1/admin/passkeys/Returns all passkeys across all users in the organization, paginated. Requires an admin-scoped API key.
Query parameters
| Parameter | Type | Description |
|---|---|---|
user_id |
string | Filter results to a specific user’s credentials |
page |
integer | Page number (default 1) |
per_page |
integer | Results per page (default 25, max 100) |
Request
curl "https://api.arbitex.ai/api/v1/admin/passkeys/?user_id=usr_01HZ_ALICE&page=1&per_page=25" \ -H "Authorization: Bearer arb_live_your-api-key-here"Response 200 OK
{ "data": [ { "credential_id": "a3f8e2b1-4c7d-4e9a-8b6f-1d2e3f4a5b6c", "user_id": "usr_01HZ_ALICE", "aaguid": "adce0002-35bc-c60a-648b-0b25f1f05503", "transports": ["internal", "hybrid"], "created_at": "2026-03-14T10:00:00Z", "last_used_at": "2026-03-14T09:48:00Z", "sign_count": 42 } ], "total": 1, "page": 1, "per_page": 25}| Field | Type | Description |
|---|---|---|
credential_id |
string (UUID) | Arbitex-assigned credential identifier |
user_id |
string | Owning user’s identifier |
aaguid |
string (UUID) | Authenticator Attestation GUID |
transports |
array of string | Transport hints reported at registration |
created_at |
datetime | ISO 8601 registration timestamp |
last_used_at |
datetime | null | ISO 8601 timestamp of the most recent successful authentication; null if never used after enrollment |
sign_count |
integer | Current authenticator signature counter |
Get passkey detail
Section titled “Get passkey detail”GET /api/v1/admin/passkeys/{credential_id}Returns full metadata for a single credential.
Path parameters
| Parameter | Description |
|---|---|
credential_id |
UUID of the credential |
Request
curl "https://api.arbitex.ai/api/v1/admin/passkeys/a3f8e2b1-4c7d-4e9a-8b6f-1d2e3f4a5b6c" \ -H "Authorization: Bearer arb_live_your-api-key-here"Response 200 OK
{ "credential_id": "a3f8e2b1-4c7d-4e9a-8b6f-1d2e3f4a5b6c", "user_id": "usr_01HZ_ALICE", "aaguid": "adce0002-35bc-c60a-648b-0b25f1f05503", "transports": ["internal", "hybrid"], "backup_eligible": true, "backup_state": true, "created_at": "2026-03-14T10:00:00Z", "last_used_at": "2026-03-14T09:48:00Z", "sign_count": 42, "status": "active"}| Field | Type | Description |
|---|---|---|
backup_eligible |
boolean | Whether the credential is eligible for cloud backup (platform passkeys) |
backup_state |
boolean | Whether the credential is currently backed up |
status |
string | active, suspended (sign count anomaly), or revoked |
Error responses
| Status | Description |
|---|---|
404 |
Credential not found |
Revoke a passkey
Section titled “Revoke a passkey”DELETE /api/v1/admin/passkeys/{credential_id}Permanently revokes a passkey. The credential is immediately invalidated — any in-flight authentication ceremony using this credential will fail. This operation cannot be undone.
Path parameters
| Parameter | Description |
|---|---|
credential_id |
UUID of the credential to revoke |
Request
curl -X DELETE \ "https://api.arbitex.ai/api/v1/admin/passkeys/a3f8e2b1-4c7d-4e9a-8b6f-1d2e3f4a5b6c" \ -H "Authorization: Bearer arb_live_your-api-key-here"Response 204 No Content
Error responses
| Status | Description |
|---|---|
404 |
Credential not found |
Complete registration flow example
Section titled “Complete registration flow example”The full passkey enrollment sequence from a web client:
Step 1 — Request creation options
curl -X POST "https://api.arbitex.ai/api/auth/passkey/register/begin" \ -H "Authorization: Bearer arb_session_eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9..." \ -H "Content-Type: application/json" \ -d '{}'The server returns PublicKeyCredentialCreationOptions. Pass this directly to the browser:
const options = await response.json();const credential = await navigator.credentials.create({ publicKey: options });Step 2 — Submit the attestation
curl -X POST "https://api.arbitex.ai/api/auth/passkey/register/complete" \ -H "Authorization: Bearer arb_session_eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9..." \ -H "Content-Type: application/json" \ -d '{ "credential": { "id": "<credential.id>", "rawId": "<base64url(credential.rawId)>", "response": { "attestationObject": "<base64url(credential.response.attestationObject)>", "clientDataJSON": "<base64url(credential.response.clientDataJSON)>" }, "type": "public-key" } }'Response — the enrolled passkey is now active:
{ "credential_id": "a3f8e2b1-4c7d-4e9a-8b6f-1d2e3f4a5b6c", "created_at": "2026-03-14T10:00:00Z", "aaguid": "adce0002-35bc-c60a-648b-0b25f1f05503", "transports": ["internal", "hybrid"]}Complete authentication flow example
Section titled “Complete authentication flow example”Step 1 — Request authentication options
curl -X POST "https://api.arbitex.ai/api/auth/passkey/authenticate/begin" \ -H "Content-Type: application/json" \ -d '{}'Pass the returned PublicKeyCredentialRequestOptions to the browser:
const options = await response.json();const assertion = await navigator.credentials.get({ publicKey: options });Step 2 — Submit the assertion
curl -X POST "https://api.arbitex.ai/api/auth/passkey/authenticate/complete" \ -H "Content-Type: application/json" \ -d '{ "credential": { "id": "<assertion.id>", "rawId": "<base64url(assertion.rawId)>", "response": { "authenticatorData": "<base64url(assertion.response.authenticatorData)>", "clientDataJSON": "<base64url(assertion.response.clientDataJSON)>", "signature": "<base64url(assertion.response.signature)>", "userHandle": "<base64url(assertion.response.userHandle)>" }, "type": "public-key" } }'Response — use the session token for all subsequent API calls:
{ "token": "arb_session_eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9...", "user_id": "usr_01HZ_ALICE", "credential_id": "a3f8e2b1-4c7d-4e9a-8b6f-1d2e3f4a5b6c", "sign_count": 43}Error responses
Section titled “Error responses”| Status | Description |
|---|---|
400 Bad Request |
Challenge expired or required field missing |
401 Unauthorized |
Missing session token or signature verification failed |
403 Forbidden |
Valid credentials but insufficient scope (e.g., non-admin calling an admin endpoint) |
404 Not Found |
User or credential not found |
409 Conflict |
Sign count anomaly (potential cloning), last-credential removal attempt, or registration cap reached |
422 Unprocessable Entity |
Malformed attestation or assertion data |
See also
Section titled “See also”- Authentication overview — configuring passkey enrollment policies and MFA requirements
- Users and groups API reference — user management and group membership
- Admin operations API reference — session management and audit log
- Policy engine API reference — policy rules and enforcement configuration