**Goal:** Add passkeys (WebAuthn) as an MFA factor alongside existing TOTP, with a long-term path toward passwordless sign-in.
## Motivation
Passkeys provide a phishing-resistant, convenient alternative to TOTP codes. Short-term, they serve as an additional MFA option (fingerprint/Face ID instead of typing a 6-digit code). Long-term, they enable passwordless sign-in once adoption is sufficient.
## Approach
**Logto-native WebAuthn (Approach A).** All WebAuthn ceremony handling, credential storage, and factor management stays in Logto via the Experience API and Management API. The SaaS backend adds hierarchical policy enforcement and exposes Logto's credential data through its own API. No custom WebAuthn libraries, no credential mirroring — we work within what Logto provides.
## 1. Policy Model
### Two Independent Policy Domains
Vendor and tenant policies are **independent scopes**, not a hierarchy. No inheritance, no floor.
**Example scenario:** Vendor requires passkey for tenant admin platform logins. A tenant admin sets `mfa_mode: off` for their org. Result: that tenant admin must use a passkey to access the SaaS platform, but their end users sign into the cameleer dashboard with just username/password.
### Policy Settings
Each policy domain has three settings:
| Setting | Values | Default | Meaning |
|---------|--------|---------|---------|
| `mfa_mode` | `off`, `optional`, `required` | `off` | Whether MFA is required for sign-in |
| `passkey_enabled` | `true`, `false` | `false` | Whether passkeys are available as a factor |
-`passkey_mode: optional` — User can choose passkey or TOTP
-`passkey_mode: preferred` — Passkey prompted first, TOTP available as fallback
-`passkey_mode: required` — Passkey is the only accepted MFA factor
### Storage
- **Vendor policy:** `vendor_auth_policy` single-row table with a management endpoint for runtime updates. Changes survive restarts without redeployment.
- **Tenant policy:** Existing `settings` JSONB column on `tenants` table, extending the current `mfaRequired` key. New keys: `mfaMode`, `passkeyEnabled`, `passkeyMode`. The existing `mfaRequired: true` maps to `mfaMode: "required"` (backward-compatible).
### Enforcement
- **`MfaEnforcementFilter`** expands to cover two route groups:
-`/api/vendor/**`, `/api/portal/**` — Checked against vendor auth policy
-`/api/tenant/**` — Checked against tenant auth policy (from tenant `settings`)
- Filter reads JWT claims `mfa_enrolled` and `passkey_enrolled` to determine user's factor status
- Error codes returned:
-`MFA_REQUIRED` — User must enroll in some MFA factor
-`PASSKEY_REQUIRED` — Passkey specifically required by policy
- Frontend uses these error codes to route users to the correct enrollment flow
## 2. Passkey Flows
### 2.1 Registration (Three Entry Points)
All three entry points use the same underlying WebAuthn registration ceremony via Logto Experience API.
4. Backend calls Logto Management API to create a WebAuthn verification for the user, returns WebAuthn registration options (challenge, RP info, user info)
5. Browser executes `navigator.credentials.create()` with the options via `@simplewebauthn/browser`
6. Frontend sends the credential attestation to `POST /api/tenant/mfa/webauthn/register/complete`
7. Backend forwards attestation to Logto Management API to complete registration, passkey appears in device list
**Post-sign-in nudge (organic adoption):**
1. User signs in with password + TOTP (or password only if MFA optional)
2. If passkey is enabled for the policy domain and user has no passkeys enrolled, show a dismissible banner: "Sign in faster with a passkey"
3. User clicks "Set up" → same registration ceremony as settings page
4. User clicks "Not now" → dismiss for 30 days (stored in `localStorage`)
**Onboarding wizard (new users):**
1. New step after account creation, before tenant provisioning: "Secure your account with a passkey"
2. Same registration ceremony
3. "Skip" button available — passkey is not forced during onboarding regardless of policy (user hasn't joined an org yet, so no policy applies)
### 2.2 Authentication (Sign-In Flow)
1. User enters email + password → Logto validates credentials
2. If MFA required (per effective policy), check enrolled factors:
- **Passkey + TOTP enrolled:** Show last-used method by default. "Use [other method] instead" link below.
- **Neither enrolled, MFA required:** Redirect to enrollment flow
3. Smart default: read `mfa_method_preference` from JWT custom data claim. Updated on each successful verification. Sign-in UI reads this to decide which prompt to show first.
4. On successful factor verification → Logto completes session, issues token with `mfa_enrolled: true` and `passkey_enrolled: true`
### 2.3 WebAuthn Ceremony Details
**Registration via settings page (Management API — user is already signed in):**
```
1. Frontend → SaaS backend: POST /api/tenant/mfa/webauthn/register/start
2. SaaS backend → Logto Management API: POST /api/users/{userId}/mfa-verifications
(type: "WebAuthn") → returns registration options (challenge, RP, user info)
**Backward compatibility:** `mfaRequired: true` treated as `mfaMode: "required"`. The filter checks both: if `mfaMode` is present, use it; otherwise fall back to `mfaRequired`.
### 3.2 MfaEnforcementFilter Changes
Current behavior: only intercepts `/api/tenant/**`, checks `mfa_enrolled` claim against tenant `settings.mfaRequired`.
New behavior:
- **Route matching:** Intercept `/api/vendor/**` and `/api/portal/**` in addition to `/api/tenant/**`
- **Exempt routes:** Add `/api/vendor/auth-policy` and `/api/portal/auth-settings` to exempt list so admins can read/update policy without triggering enforcement
### 3.3 LogtoManagementClient Additions
New methods for WebAuthn credential management:
| Method | Logto Endpoint | Purpose |
|--------|---------------|---------|
| `listWebAuthnCredentials(userId)` | `GET /api/users/{userId}/mfa-verifications` | List credentials, filter by `type: "WebAuthn"` |
This handles Base64URL encoding, browser compatibility, and platform authenticator detection.
## 5. Platform UI Changes
### 5.1 MFA Settings Page Extension
Add a "Passkeys" section below the existing TOTP section in `SettingsPage.tsx`:
**When no passkeys enrolled:**
- "Add a passkey" button
- Brief explanation: "Use your fingerprint, face, or security key to sign in"
**When passkeys exist:**
- Table/list of registered passkeys showing:
- Name (editable inline or via edit button)
- Device info (parsed from user-agent string — "Chrome on Windows", "Safari on iPhone", etc.)
- Created date
- Delete button (with confirmation dialog)
- "Add another passkey" button
### 5.2 Auth Policy Management
**Vendor settings (new page or section):**
- Under vendor admin area, "Authentication Policy" section
- Three controls matching the policy settings (mfa_mode dropdown, passkey_enabled toggle, passkey_mode dropdown)
- Passkey_mode control disabled when passkey_enabled is false
**Tenant settings (extend existing):**
- Current MFA toggle (`mfaRequired`) replaced with richer controls
- Same three controls as vendor, scoped to tenant users
- Backward-compatible: existing `mfaRequired: true` shown as `mfaMode: required`
### 5.3 Onboarding Wizard
Add optional passkey registration step to `OnboardingPage.tsx`:
- After org creation, before redirect
- "Secure your account with a passkey" with setup and skip buttons
- Only shown if passkeys are enabled in vendor policy (read from `/api/config`)
## 6. Passwordless Future Path
This design enables passwordless sign-in without additional architecture changes:
1.**`passkey_mode: required`** already means passkey is the only accepted factor
2. When Logto supports passkey-as-primary-factor (passwordless sign-in), the sign-in UI adds a "Sign in with passkey" button on the initial screen (before email/password)
3. The policy model already supports this: a future `auth_mode: passwordless` setting could skip the password step entirely
4. No backend changes needed — the JWT claims and enforcement filter already handle passkey-only scenarios
This is not part of the current implementation scope but validates that the design doesn't paint us into a corner.
## 7. Out of Scope
- Passwordless sign-in (passkey as primary factor, no password) — future work
- Conditional UI / autofill-assisted passkey discovery — depends on Logto Experience API support
- Cross-device passkey sync management — handled by OS/browser, not our concern
- FIDO2 attestation policy (which authenticators to trust) — Logto defaults are sufficient
- Rate limiting on WebAuthn ceremonies — handled by Logto
## 8. Dependencies
- Logto v1.11.0+ (WebAuthn MFA support) — current `ghcr.io/logto-io/logto:latest` satisfies this
-`@simplewebauthn/browser` npm package for sign-in UI and platform UI
- No Java WebAuthn libraries needed (all ceremonies handled by Logto)