A passkey is a FIDO2/WebAuthn credential - Face ID, Touch ID, Windows Hello, a phone or a USB
security key - that replaces the employee's password entirely. Login is usernameless: the
authentication options endpoint accepts no username and returns no allowCredentials, so the
browser's own discoverable-credential picker decides which account logs in, and the endpoint
reveals nothing about which accounts exist.
A passkey requires user verification (biometric or PIN) at both registration and authentication.
Because that is itself two factors, a successful passkey login skips SMS and authenticator (TOTP)
validation - security.token.clearSmsKey() still runs, exactly as it does after a password
login.
This page is the overview for the whole feature. The endpoints themselves are documented on:
Related collections and keys: portals, employeewebauthncredentials, employeewebauthnenrolltokens, webauthn.
A WebAuthn credential is bound to one relying party id (rpId), which must be the exact
hostname or a parent domain of the origin it was created on. The allowlist of origins that may
authenticate, and which rpId each of them maps to, is the Portals
collection - keyed by hostname, not hard-coded, so adding a reseller portal is a database row.
telecomx.dk. It is effectively permanent - rpId isbaked into every stored credential, so changing the anchor later would invalidate all of them.
rpId cannot serve it directly (i.e. the hostname is neither the
rpId itself nor a subdomain of it) can only authenticate against the anchor by being
published as a **Related Origin Request**. Browsers fetch
''https://telecomx.dk/.well-known/webauthn'' (served by ''GET /.well-known/webauthn'', proxied
there by web1/web2) and accept any origin listed in its ''origins'' array.
* The well-known file is generated from the collection, not hand-maintained: every ''ACTIVE''
portal with ''relatedOrigin: true'', as ''https://<hostname>''.
* **Only 5 unique registrable-domain (eTLD+1) labels are guaranteed to be honoured by a
browser** - surplus entries are silently ignored. Writing a portal with ''relatedOrigin: true''
is refused with ''relatedOrigin'' / 422 if it would push the count of unique foreign labels
above 5. The anchor's own label (''telecomx'') is free and never counts.
* A portal's ''rpId'' is refused outright if it is a single label such as ''dk'' or ''com'' - a
public suffix, which no browser will ever accept as a relying party. ''localhost'' is the one
legitimate single-label exception, and only in development. See [[api:tools-admin:portals]] for
the exact write-time checks.
Employee.webauthnOnly makes passkey login mandatory for that account. Turning it on blocks
every other way of authenticating with a password:
| Where | Result |
|---|---|
POST /auth/login | 401 webauthn_only - see login |
GET/POST /auth/reset | 403 webauthn_only |
GET/POST /auth/setpassword | 403 webauthn_only |
| CardDAV Basic Auth | 401, unless the “password” presented is one of the employee's own persistent tokens - see carddav-token |
Two things that are not blocked, deliberately:
CardDAV, minted separately as an app-specific password), so webauthnOnly never revokes a
token that is already out there.
* **Impersonation (''GET /auth/impersonate/:employee'') is not blocked.** No password is
presented in that flow, so there is nothing for the flag to intercept.
Writing the flag is guarded so it can never lock an account out: it is refused with
webauthnOnly / 422 on an employee with zero registered credentials, and it is mutually
exclusive with m2mOnly (setting one while the other is already true is also webauthnOnly /
422).
Flipping webauthnOnly on does not itself invalidate whatever tokens the employee already
holds - a session token issued five minutes earlier under the password path keeps working until it
expires. An admin who wants the switch to take effect immediately should follow it with
GET /employee/:id/cleartokens.
An employee with no working passkey (new device, factory reset, uninstalled password manager) is recovered with a single-use enrollment token, never by falling back to a password:
POST /employee/:id/webauthn/enroll with { hostname, method } - see
webauthn. hostname must name an ACTIVE row in
[[priv:mongo:portals|Portals]]; its ''enrollUrl'' template decides where the link points, and
the ''rpId'' the eventual passkey is created for is whichever portal the employee actually opens
the link (or pastes the code) on - not necessarily the one the admin picked.
- A 32-byte, base64url token is stored in
[[priv:mongo:employeewebauthnenrolltokens|EmployeeWebauthnEnrollTokens]], keyed on the
employee's own ''_id'' - so there is at most one pending enrollment per employee, and issuing a
new one silently replaces an older unused one. It is valid for **one hour** and is consumed
exactly once, atomically, by ''POST /auth/webauthn/enroll''.
- The employee receives the token **two ways in the same e-mail or SMS**: as a clickable link
(''enrollUrl'' with ''{token}''/''{lang}'' filled in) and as a plain, copy-pasteable code (the
raw token).
- **The code is the only route that works on the macOS Communicator app.** Its Touch ID
passkeys live in an app-private Secure Enclave keychain-access-group, wired up by Electron's
''app.configureWebAuthn()'' - a credential created by clicking the link in Safari or Chrome is
created in the *browser's* store and is invisible to the Electron app, and vice versa. The
Communicator login dialog has a "Jeg har en passkey-kode" field that pastes the code in and
drives the same ''POST /auth/webauthn/enroll/options'' + ''POST /auth/webauthn/enroll'' calls
from inside the app, so the resulting credential lands in the app's own store. On Windows this
distinction does not exist - Chromium there delegates to the system's Windows Hello /
WebAuthn API, so the link works normally inside the Electron window too.
- See [[api:auth:webauthn-enroll]] for the two enrollment endpoints themselves.
| Error code | Message | Description |
|---|---|---|
| 401 | origin_invalid | Origin header missing, not https (outside development), or not a known/active portal. Thrown by every endpoint that resolves a relying party: register/options, register, authenticate/options, authenticate, enroll/options, enroll |
| 401 | challenge_invalid | The challenge id is malformed, expired, already spent, or its stored rpId/origin/employee does not match this request |
| 401 | verification_failed | The authenticator's signature or client data could not be verified |
| 401 | credential_unknown | The credential id in the response is not registered (authenticate only) |
| 401 | user_verification_required | The authenticator did not perform user verification (authenticate only) |
| 401 | account_disabled | Employee state is not ENABLED, access level is NO_LOGIN, or the employee's customer is not ACTIVE (authenticate, enroll/options, enroll) |
| 401 | m2m_only | The employee is m2mOnly and must authenticate as M2M instead (authenticate, enroll/options, enroll) |
| 401 | ipaddress_invalid | The employee's ipRestriction excludes the caller's IP (authenticate only) |
| 401 | enroll_token_invalid | The enrollment token is unknown, already consumed, or older than one hour (enroll/options, enroll) |
| 404 | customer | The employee's customer document could not be found (authenticate, enroll/options, enroll) |
| 404 | credential | The passkey does not exist or belongs to someone else (rename, delete) |
| 409 | credential | This credential id is already registered - to anyone (register, enroll) |
| 422 | last_credential | Refused: this is the last passkey on a webauthnOnly account (delete) |
| 429 | (empty) | Rate limit exceeded on the login scope (authenticate/options, authenticate, enroll/options, enroll) |
Full detail on each of these is on the endpoint's own page.
| Error code | Message | Description |
|---|---|---|
| 401 / 403 | webauthn_only | Password path attempted on a webauthnOnly account - see “webauthnOnly” above. Never returned by an /auth/webauthn/* endpoint itself |
| 422 | webauthnOnly | webauthnOnly set with zero credentials, or together with m2mOnly - POST /employee, POST /employee/:id |
| 404 | not_found | Employee does not exist - GET/DELETE /employee/:id/webauthn*, POST /employee/:id/webauthn/enroll (pre-existing Employee.load behaviour, not specific to passkeys) |
| 422 | hostname | hostname does not name a known portal - POST /employee/:id/webauthn/enroll |
| 409 | hostname | Portal hostname already exists - portals |
| 422 | rpId | rpId is a public suffix, or cannot serve the hostname without relatedOrigin - portals |
| 422 | relatedOrigin | Publishing this portal as a related origin would exceed the 5-label budget - portals |
| 404 | portal | Portal does not exist - portals |
Every outcome of registration, authentication and enrollment is counted in one Prometheus counter,
tcx_api_webauthn_events_total{kind, result, code} (kind ∈ registration,
authentication, enrollment; result ∈ ok, failed). code is bounded to a fixed
set of labels so no caller-controlled value can ever become a label.
It emits ok (no code), and on failure: challenge_invalid, verification_failed,
credential (the 409 duplicate-registration case only), account_disabled, m2m_only,
customer, enroll_token_invalid, credential_unknown and user_verification_required.
It does not emit: origin_invalid (thrown by relying-party resolution, before any counter
call), the 404 credential case from rename/delete, last_credential, webauthn_only, the
429 rate-limit rejection, or any of the /employee/:id/webauthn/enroll and /tools/portals
specific codes (hostname, rpId, relatedOrigin, portal, webauthnOnly,
not_found). Those flows do not touch this counter at all.