Skip to content

Passkeys (WebAuthn / FIDO2)

Overview

Passkeys let users sign in without a password using Touch ID, Face ID, Windows Hello, an Android fingerprint, or a hardware security key. The private key never leaves the user's device (or its password-manager sync fabric); the server stores only the public key and verifies signatures.

ODP implements passkeys as a passwordless primary login method:

  • Usernameless / discoverable: the user doesn't type an email — the browser lists the passkeys it holds for this domain and one gesture signs them in. Registration therefore requires a resident key (residentKey: 'required'), which every modern platform authenticator supports.
  • Password stays as a fallback login method (unless disabled via SSO settings).
  • Phishing-resistant by construction: a passkey is cryptographically bound to the Relying Party ID (domain). A look-alike domain can never request a signature from it.

How it relates to TFA

A passkey verified with userVerification: 'required' combines possession (the device) with user verification (biometric/PIN) — it is MFA-equivalent on its own. Passkey login therefore skips the TOTP step. Post-login actions are not skipped (see Password policy interplay).

Server configuration

Three environment variables on the API (service/api):

VariableDefaultDescription
WEBAUTHN_RP_IDhostname of PUBLIC_URLThe Relying Party ID — the domain of the admin frontend (no scheme, no port). Passkeys are permanently bound to it.
WEBAUTHN_ORIGINorigin of PUBLIC_URLComma-separated list of browser origins allowed to complete ceremonies (full origin incl. scheme/port).
WEBAUTHN_RP_NAMEODPDisplay name shown in the OS passkey-creation prompt. Cosmetic only.

rpID is immutable — decide it before going live

Changing WEBAUTHN_RP_ID invalidates every registered passkey (users must re-register). Pin the final admin domain before the first real user registers a passkey.

Origin is the FRONTEND's, not the API's

The browser signs clientDataJSON.origin = the origin of the page the user is on (the admin webapp / your app), not the API's URL. The PUBLIC_URL-derived defaults are only correct when the API and the frontend share an origin — in any split deployment set both variables explicitly.

bash
# Local dev (webapp on vite :3000 — open via localhost, an IP is not a valid rpID)
WEBAUTHN_RP_ID=localhost
WEBAUTHN_ORIGIN=http://localhost:3000

# Production
WEBAUTHN_RP_ID=admin.example.com
WEBAUTHN_ORIGIN=https://admin.example.com

HTTPS is mandatory everywhere except localhost — browsers refuse WebAuthn on plain HTTP.

Data model

One row per credential in odp_user_passkeys:

ColumnDescription
idUUID
user_idFK → odp_users, cascade delete
credential_idBase64url credential id from the authenticator (unique)
public_keyBase64url COSE public key
counterSignature counter — clone/replay detection
transportsJSON array, e.g. ["internal","hybrid"]
aaguidAuthenticator model identifier
device_labelUser-chosen name ("MacBook Touch ID")
backed_uptrue = synced passkey (iCloud Keychain / Google Password Manager), false = device-bound
created_at, last_used_atTimestamps

A user can hold multiple passkeys (one per ecosystem/device). Ceremony challenges are not stored here — they live in odp_ephemeral_codes (type webauthn-register / webauthn-auth) with a 5-minute TTL and are single-use.

Endpoints

All under /auth/passkey. Management + registration require authentication; the login ceremony is public.

MethodPathAuthPurpose
GET/auth/passkeyList the caller's passkeys (safe fields only)
PATCH/auth/passkey/:idRename ({ "device_label": "..." })
DELETE/auth/passkey/:idDelete (see anti-lockout)
POST/auth/passkey/register/optionsStart registration ceremony
POST/auth/passkey/register/verifyFinish registration, store credential
POST/auth/passkey/login/optionsStart authentication ceremony (usernameless)
POST/auth/passkey/login/verifyFinish authentication, issue tokens

Registration ceremony (user is logged in)

1. POST /auth/passkey/register/options — empty body.

Response 200

json
{
  "data": {
    "options": {
      "rp": { "id": "admin.example.com", "name": "ODP Admin" },
      "user": { "id": "…", "name": "user@example.com", "displayName": "…" },
      "challenge": "base64url…",
      "excludeCredentials": [ { "id": "…", "transports": ["internal"] } ],
      "authenticatorSelection": { "residentKey": "required", "userVerification": "required" },
      "pubKeyCredParams": [  ]
    },
    "code": "opaque-challenge-code"
  }
}
  • options goes straight into the browser ceremony (navigator.credentials.create).
  • code is an opaque handle to the server-stored challenge — echo it back in the verify call. It expires after 5 minutes and is single-use.
  • excludeCredentials prevents registering the same authenticator twice.

2. Run the browser ceremony (user gesture — Touch ID / fingerprint / PIN).

3. POST /auth/passkey/register/verify

json
{
  "response": { …attestation JSON from the browser… },
  "code": "opaque-challenge-code",
  "device_label": "MacBook Touch ID"
}

device_label is optional. Response 201 returns the stored passkey (safe fields — never the public key material):

json
{
  "data": {
    "id": "uuid",
    "device_label": "MacBook Touch ID",
    "transports": ["internal"],
    "backed_up": true,
    "created_at": "2026-08-24T…",
    "last_used_at": null
  }
}

Authentication ceremony (public, usernameless)

1. POST /auth/passkey/login/options — empty body, no auth.

Response 200

json
{
  "data": {
    "options": {
      "rpId": "admin.example.com",
      "challenge": "base64url…",
      "timeout": 60000,
      "userVerification": "required"
    },
    "code": "opaque-challenge-code"
  }
}

No allowCredentials — that is what makes the flow usernameless: the browser offers whichever resident passkeys it holds for this rpID.

2. Run the browser ceremony (navigator.credentials.get).

3. POST /auth/passkey/login/verify

json
{
  "response": { …assertion JSON from the browser… },
  "code": "opaque-challenge-code",
  "mode": "session"
}

Response 200 — the same AuthData contract as POST /auth/login:

json
{
  "data": {
    "access_token": "eyJ…",
    "refresh_token": "…",
    "expires": 900000,
    "post_login_action": "none"
  }
}
  • mode: "session" sets the refresh token as an HttpOnly cookie, exactly like password login.
  • post_login_action may be non-none — handle it the same way as password login (see below).
  • On success the server bumps the credential's counter and last_used_at.

Integrating from a frontend or another app

The ceremonies are plain WebAuthn — any stack works. The recommended client library is @simplewebauthn/browser (the server side uses @simplewebauthn/server v13, so the JSON shapes match its optionsJSON inputs directly).

Checklist

  1. Feature-detect before showing any passkey UI: browserSupportsWebAuthn() (or !!window.PublicKeyCredential).
  2. Register (user must be logged in):
ts
import { startRegistration } from '@simplewebauthn/browser'

const { options, code } = (await post('/auth/passkey/register/options', {})).data
const response = await startRegistration({ optionsJSON: options })   // user gesture
await post('/auth/passkey/register/verify', { response, code, device_label: 'My MacBook' })
  1. Login (button / modal):
ts
import { startAuthentication } from '@simplewebauthn/browser'

const { options, code } = (await post('/auth/passkey/login/options', {})).data
const response = await startAuthentication({ optionsJSON: options }) // user picks a passkey
const auth = (await post('/auth/passkey/login/verify', { response, code, mode: 'session' })).data
// → store/handle tokens exactly like a password login
  1. Conditional UI (autofill) — optional but the best UX: the browser suggests passkeys inside the login form's autofill dropdown, zero clicks needed.
    • Add autocomplete="username webauthn" to the username/email input.
    • On page load: browserSupportsWebAuthnAutofill() → fetch login options → startAuthentication({ optionsJSON, useBrowserAutofill: true }). The promise stays pending until the user picks a passkey; treat AbortError/NotAllowedError as a silent no-op (the user used another method).
  2. Handle user cancellation: startRegistration/startAuthentication throw NotAllowedError when the user dismisses the OS prompt — this is not an error, return to idle.
  3. Handle post_login_action on login exactly as for password login (must_change_password / password_expired / tfa_setup_required → route to the forced-action screen, then refresh).
  4. Management UI: list (GET), rename (PATCH), delete (DELETE). Surface the delete-guard error (below) to the user verbatim.

The admin webapp's own integration is a working reference: apps/webapp/modules/auth/composables/use-passkey.ts (ceremonies), components/profile/Passkeys.vue (management), pages/Login.vue (button + conditional UI). BFF forwarding: apps/webapp/server/src/repositories/auth/{controllers,services}/passkey.*.

Error responses

StatusWhen
400 INVALID_PAYLOADMissing response/code; expired, reused, or foreign registration challenge; attestation failed verification
401 INVALID_CREDENTIALSLogin verify failed: unknown credential, bad signature, counter replay, expired/reused challenge, suspended user
403 FORBIDDENRegister/management endpoints called without authentication
409 CONFLICTCredential already registered

Failed and successful logins emit LOGIN_FAILED / LOGIN_SUCCESS security events with provider: "passkey".

Security properties

  • Challenge store: server-generated random challenge per ceremony, 5-minute TTL, deleted on first use — replaying a captured verify payload fails.
  • Counter enforcement: each assertion must carry a signature counter greater than the stored one; a cloned authenticator falls behind and is rejected (401).
  • Origin + rpID binding: verifyAuthenticationResponse checks the browser-signed origin against WEBAUTHN_ORIGIN and the rpID hash against WEBAUTHN_RP_ID — tokens can only be minted from the configured frontend.
  • No secrets exposed: list/read responses never include public_key or credential_id.
  • Login throttle interplay: passkey login is not throttled (challenge signatures are not brute-forceable); a successful passkey login clears the password-attempt throttle so the account owner is never held to an attacker's delay.

Anti-lockout guard

DELETE /auth/passkey/:id refuses (400) to delete the user's last passkey while password login is disabled and the user has no linked SSO provider — deleting it would lock them out permanently.

Password policy interplay

Two rules to remember when combining passkeys with the password policy features (#44/#45/#46):

  1. Post-login actions are never bypassed by passkey login. If the account has must_change_password, password_expired, or tfa_setup_required pending, login/verify returns it in post_login_action with a scope-limited access token — identical to password login. Skipping them via the passkey path would defeat admin-forced policies (passkey users do hold a local password). SSO/SAML logins are the exception: they never present the password credential — and SSO-only users may not have one — so password-based actions do not gate them.

  2. An admin changing another user's password forces a change on next login. PATCH /users/:id with a password and no current_password (i.e., an admin reset) automatically sets require_password_change = true — the admin knows that password, so it is treated as temporary. The same applies to POST /users with an initial password. A self-service change (with current_password) clears the flag. Callers that intentionally set a permanent password opt out explicitly:

json
PATCH /users/:id
{ "password": "new-password", "require_password_change": false }

Note this also bites passkey users: reset someone's password and their next login — even via passkey — lands on the forced change-password screen (rule 1).

ODP Internal API Documentation