Table of Contents
API : Passkey Authenticate
Introduction
Second step of a passkey login: verifies the browser's WebAuthn assertion and, on success, issues a token exactly as /auth/login does. A user-verified passkey counts as two factors, so SMS and authenticator (TOTP) validation are skipped for this login. Unauthenticated.
See webauthn-authenticate-options for the preceding step and webauthn for the relying-party model and the full error-code table.
Request
| URL | https://api.telecomx.dk/auth/webauthn/authenticate | ||
|---|---|---|---|
| Method | POST | ||
| Header | Origin | String | Required. Must be the same origin the challenge was minted for |
| Body | JSON object | ||
| challengeId | String | The id returned by webauthn-authenticate-options, exactly 43 characters | |
| response | Object | The AuthenticationResponseJSON produced by the browser's navigator.credentials.get() (e.g. via startAuthentication()) |
|
| cookie | Boolean | Optional. True if the server should also set the token as a cookie, default false | |
| persist | Boolean | Optional. True for a token that does not expire until logout, default false | |
cookie and persist must be JSON booleans (true/false, not the strings
“true”/“false”). Unlike /auth/login, which coerces query/body strings with a bool()
helper, this endpoint's body is Zod-validated as z.boolean().optional() - a string value fails
validation instead of being coerced.
Body example
{ challengeId: 'Zm9vYmFyYmF6cXV4Y29ycmVjdGhvcnNlYmF0dGVyeXN0YXBsZQ', response: { id: 'AXCxIVI...', rawId: 'AXCxIVI...', type: 'public-key', response: { clientDataJSON: '...', authenticatorData: '...', signature: '...', userHandle: '...' } }, cookie: false, persist: false }
Response
Identical to login's response - the same token, expiresInMinutes, employee and
customer fields, employeeFeatures, etc. See that page rather than this one for the full field
table; nothing in the shape changes for a passkey login.
The LoginLog entry this login writes carries method: 'PASSKEY' - see
loginlog.
Errors
| Error code | Message | Description |
|---|---|---|
| 401 | origin_invalid | Origin header is missing, not https (outside development), or not a known/active portal |
| 401 | challenge_invalid | Challenge id is malformed, expired, already used, or was minted for a different origin |
| 401 | credential_unknown | response.id does not match any registered credential for this relying party |
| 401 | verification_failed | The signature or client data could not be verified |
| 401 | user_verification_required | The authenticator did not perform user verification |
| 401 | account_disabled | Employee state is not ENABLED, or access level is NO_LOGIN, or the customer is not ACTIVE |
| 401 | m2m_only | The employee is m2mOnly and must authenticate as M2M instead |
| 404 | customer | The employee's customer document could not be found |
| 401 | ipaddress_invalid | The employee's ipRestriction excludes the caller's IP |
| 429 | (empty) | Rate limit exceeded on the login scope, keyed by the caller's IP |
An unknown credential, a failed verification, or a missing employee record each also register a brute-force attempt against the caller's IP, exactly as a failed password login does.