Appearance
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):
| Variable | Default | Description |
|---|---|---|
WEBAUTHN_RP_ID | hostname of PUBLIC_URL | The Relying Party ID — the domain of the admin frontend (no scheme, no port). Passkeys are permanently bound to it. |
WEBAUTHN_ORIGIN | origin of PUBLIC_URL | Comma-separated list of browser origins allowed to complete ceremonies (full origin incl. scheme/port). |
WEBAUTHN_RP_NAME | ODP | Display 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.comHTTPS is mandatory everywhere except localhost — browsers refuse WebAuthn on plain HTTP.
Data model
One row per credential in odp_user_passkeys:
| Column | Description |
|---|---|
id | UUID |
user_id | FK → odp_users, cascade delete |
credential_id | Base64url credential id from the authenticator (unique) |
public_key | Base64url COSE public key |
counter | Signature counter — clone/replay detection |
transports | JSON array, e.g. ["internal","hybrid"] |
aaguid | Authenticator model identifier |
device_label | User-chosen name ("MacBook Touch ID") |
backed_up | true = synced passkey (iCloud Keychain / Google Password Manager), false = device-bound |
created_at, last_used_at | Timestamps |
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.
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /auth/passkey | ✅ | List the caller's passkeys (safe fields only) |
| PATCH | /auth/passkey/:id | ✅ | Rename ({ "device_label": "..." }) |
| DELETE | /auth/passkey/:id | ✅ | Delete (see anti-lockout) |
| POST | /auth/passkey/register/options | ✅ | Start registration ceremony |
| POST | /auth/passkey/register/verify | ✅ | Finish registration, store credential |
| POST | /auth/passkey/login/options | ❌ | Start authentication ceremony (usernameless) |
| POST | /auth/passkey/login/verify | ❌ | Finish 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"
}
}optionsgoes straight into the browser ceremony (navigator.credentials.create).codeis an opaque handle to the server-stored challenge — echo it back in the verify call. It expires after 5 minutes and is single-use.excludeCredentialsprevents 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_actionmay be non-none— handle it the same way as password login (see below).- On success the server bumps the credential's
counterandlast_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
- Feature-detect before showing any passkey UI:
browserSupportsWebAuthn()(or!!window.PublicKeyCredential). - 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' })- 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- 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; treatAbortError/NotAllowedErroras a silent no-op (the user used another method).
- Add
- Handle user cancellation:
startRegistration/startAuthenticationthrowNotAllowedErrorwhen the user dismisses the OS prompt — this is not an error, return to idle. - Handle
post_login_actionon login exactly as for password login (must_change_password/password_expired/tfa_setup_required→ route to the forced-action screen, then refresh). - 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
| Status | When |
|---|---|
400 INVALID_PAYLOAD | Missing response/code; expired, reused, or foreign registration challenge; attestation failed verification |
401 INVALID_CREDENTIALS | Login verify failed: unknown credential, bad signature, counter replay, expired/reused challenge, suspended user |
403 FORBIDDEN | Register/management endpoints called without authentication |
409 CONFLICT | Credential 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:
verifyAuthenticationResponsechecks the browser-signed origin againstWEBAUTHN_ORIGINand the rpID hash againstWEBAUTHN_RP_ID— tokens can only be minted from the configured frontend. - No secrets exposed: list/read responses never include
public_keyorcredential_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):
Post-login actions are never bypassed by passkey login. If the account has
must_change_password,password_expired, ortfa_setup_requiredpending,login/verifyreturns it inpost_login_actionwith 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.An admin changing another user's password forces a change on next login.
PATCH /users/:idwith apasswordand nocurrent_password(i.e., an admin reset) automatically setsrequire_password_change = true— the admin knows that password, so it is treated as temporary. The same applies toPOST /userswith an initial password. A self-service change (withcurrent_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).