- Add V002/V003 migrations and VendorAuthPolicy classes to CLAUDE.md - Document MFA & passkey enforcement model in config CLAUDE.md - Mark passkey MFA design spec as Implemented Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
17 KiB
Passkey MFA Design
Date: 2026-04-27 Status: Implemented 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.
| Policy | Scope | Who it affects | Who sets it |
|---|---|---|---|
| Vendor auth policy | SaaS platform logins (/platform/*) |
Tenant admins accessing the management plane | Platform admin (vendor) |
| Tenant auth policy | Tenant server/dashboard logins | Org users accessing the tenant's cameleer-server | Tenant admin |
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, preferred, required |
optional |
Passkey enforcement level (only relevant when passkey_enabled: true) |
passkey_mode: optional— User can choose passkey or TOTPpasskey_mode: preferred— Passkey prompted first, TOTP available as fallbackpasskey_mode: required— Passkey is the only accepted MFA factor
Storage
- Vendor policy:
vendor_auth_policysingle-row table with a management endpoint for runtime updates. Changes survive restarts without redeployment. - Tenant policy: Existing
settingsJSONB column ontenantstable, extending the currentmfaRequiredkey. New keys:mfaMode,passkeyEnabled,passkeyMode. The existingmfaRequired: truemaps tomfaMode: "required"(backward-compatible).
Enforcement
MfaEnforcementFilterexpands to cover two route groups:/api/vendor/**,/api/portal/**— Checked against vendor auth policy/api/tenant/**— Checked against tenant auth policy (from tenantsettings)
- Filter reads JWT claims
mfa_enrolledandpasskey_enrolledto determine user's factor status - Error codes returned:
MFA_REQUIRED— User must enroll in some MFA factorPASSKEY_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.
Settings page (deliberate enrollment):
- User navigates to MFA settings in platform UI
- Clicks "Add passkey"
- Frontend calls SaaS backend
POST /api/tenant/mfa/webauthn/register/start - Backend calls Logto Management API to create a WebAuthn verification for the user, returns WebAuthn registration options (challenge, RP info, user info)
- Browser executes
navigator.credentials.create()with the options via@simplewebauthn/browser - Frontend sends the credential attestation to
POST /api/tenant/mfa/webauthn/register/complete - Backend forwards attestation to Logto Management API to complete registration, passkey appears in device list
Post-sign-in nudge (organic adoption):
- User signs in with password + TOTP (or password only if MFA optional)
- If passkey is enabled for the policy domain and user has no passkeys enrolled, show a dismissible banner: "Sign in faster with a passkey"
- User clicks "Set up" → same registration ceremony as settings page
- User clicks "Not now" → dismiss for 30 days (stored in
localStorage)
Onboarding wizard (new users):
- New step after account creation, before tenant provisioning: "Secure your account with a passkey"
- Same registration ceremony
- "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)
- User enters email + password → Logto validates credentials
- If MFA required (per effective policy), check enrolled factors:
- Passkey + TOTP enrolled: Show last-used method by default. "Use [other method] instead" link below.
- Passkey only: WebAuthn assertion prompt
- TOTP only: Existing TOTP code input (unchanged)
- Neither enrolled, MFA required: Redirect to enrollment flow
- Smart default: read
mfa_method_preferencefrom JWT custom data claim. Updated on each successful verification. Sign-in UI reads this to decide which prompt to show first. - On successful factor verification → Logto completes session, issues token with
mfa_enrolled: trueandpasskey_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)
3. SaaS backend → Frontend: registration options
4. Browser: navigator.credentials.create(options) → attestation response
5. Frontend → SaaS backend: POST /api/tenant/mfa/webauthn/register/complete
6. SaaS backend → Logto Management API: complete verification with attestation
Registration during sign-in (Experience API — user is mid-authentication):
1. POST /api/experience/verification/web-authn/registration → get creation options
2. Browser: navigator.credentials.create(options)
3. POST /api/experience/verification/web-authn/registration/verify → send attestation
4. POST /api/experience/identification → identify user
5. POST /api/experience/submit → complete
Assertion during sign-in (Experience API — MFA step after password):
1. POST /api/experience/verification/web-authn/authentication → get request options
2. Browser: navigator.credentials.get(options)
3. POST /api/experience/verification/web-authn/authentication/verify → send assertion
Returns: verificationId
4. POST /api/experience/identification → identify user
5. POST /api/experience/submit → complete, returns redirectTo
2.4 Device Management (Settings UI)
Users can manage their registered passkeys from the MFA settings page:
- List: Show all registered WebAuthn credentials — name (user-settable), user-agent from registration, creation date
- Rename: Update credential name via Logto Management API
- Delete: Remove credential via Management API. If it's the last passkey and passkey is required, block deletion.
Logto metadata available per credential:
id— credential identifiertype—"WebAuthn"name— user-settable display name (nullable)agent— user-agent string captured at registration timecreatedAt— timestamp
Not available from Logto: lastUsedAt — Logto does not track last-used date. The UI will not show this field.
3. Backend Changes
3.1 Database Migration
New table: vendor_auth_policy (single-row config)
| Column | Type | Default | Purpose |
|---|---|---|---|
id |
INTEGER | 1 | Single-row constraint |
mfa_mode |
VARCHAR(10) | 'off' |
off, optional, required |
passkey_enabled |
BOOLEAN | false |
Whether passkeys available |
passkey_mode |
VARCHAR(10) | 'optional' |
optional, preferred, required |
updated_at |
TIMESTAMP | now() | Last update |
Tenant settings JSONB extension: Add new keys to the whitelist in TenantPortalService.updateTenantSettings():
| Key | Type | Default | Purpose |
|---|---|---|---|
mfaMode |
string | "off" |
Replaces/supersedes mfaRequired |
passkeyEnabled |
boolean | false |
Whether passkeys available for tenant users |
passkeyMode |
string | "optional" |
Passkey enforcement level |
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/** - Policy lookup:
- Vendor/portal routes → read
vendor_auth_policytable - Tenant routes → read tenant
settings(existing behavior, extended)
- Vendor/portal routes → read
- Claim checks:
mfa_mode: required→ requiremfa_enrolled == truepasskey_mode: required→ requirepasskey_enrolled == truepasskey_mode: preferred→ same as optional for enforcement (preference handled in sign-in UI)
- Error codes:
APP_MFA_REQUIRED(existing) — MFA enrollment neededAPP_PASSKEY_REQUIRED(new) — Passkey specifically required
- Exempt routes: Add
/api/vendor/auth-policyand/api/portal/auth-settingsto 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" |
deleteWebAuthnCredential(userId, verificationId) |
DELETE /api/users/{userId}/mfa-verifications/{verificationId} |
Remove a passkey |
renameWebAuthnCredential(userId, verificationId, name) |
PATCH /api/users/{userId}/mfa-verifications/{verificationId} |
Update display name |
updateUserCustomData(userId, data) |
PATCH /api/users/{userId}/custom-data |
Set mfa_method_preference |
3.4 Custom JWT Script Update
Extend the existing getCustomJwtClaims function in docker/logto-bootstrap.sh:
const factors = context.user?.mfaVerificationFactors ?? [];
return {
roles: /* ... existing role mapping ... */,
mfa_enrolled: factors.includes('Totp') || factors.includes('WebAuthn'),
passkey_enrolled: factors.includes('WebAuthn'),
mfa_method_preference: context.user?.customData?.mfa_method_preference ?? null,
};
Changes from current script:
mfa_enrollednow returnstruefor either TOTP or WebAuthn (was TOTP-only)- New
passkey_enrolledboolean claim - New
mfa_method_preferenceclaim from user custom data
3.5 API Endpoints
Vendor auth policy (platform:admin only):
| Method | Path | Purpose |
|---|---|---|
GET |
/api/vendor/auth-policy |
Read current vendor auth policy |
PUT |
/api/vendor/auth-policy |
Update vendor auth policy |
Tenant auth settings (tenant:manage scope):
Extend existing TenantPortalController:
| Method | Path | Purpose |
|---|---|---|
GET |
/api/tenant/auth-settings |
Read tenant auth policy |
PUT |
/api/tenant/auth-settings |
Update tenant auth policy |
Passkey management (authenticated users):
| Method | Path | Purpose |
|---|---|---|
GET |
/api/tenant/mfa/webauthn |
List user's passkey credentials |
POST |
/api/tenant/mfa/webauthn/register/start |
Initiate WebAuthn registration |
POST |
/api/tenant/mfa/webauthn/register/complete |
Complete WebAuthn registration |
PATCH |
/api/tenant/mfa/webauthn/{id}/name |
Rename a passkey |
DELETE |
/api/tenant/mfa/webauthn/{id} |
Delete a passkey |
Public config extension:
GET /api/config response adds:
{
"vendorAuthPolicy": { "mfaMode": "...", "passkeyEnabled": true, "passkeyMode": "..." }
}
GET /{slug}/mfa-policy response extends to:
{
"mfaMode": "required",
"passkeyEnabled": true,
"passkeyMode": "preferred"
}
4. Sign-In UI Changes
4.1 New Modes
Add to the Mode type in SignInPage.tsx:
| Mode | When shown | UI |
|---|---|---|
mfaWebauthn |
Passkey verification during sign-in | "Verifying your identity..." with browser passkey prompt |
mfaMethodPicker |
User has both TOTP and passkey, choosing which to use | Two buttons: "Use passkey" / "Use authenticator code" |
4.2 Experience API Client Additions
New functions in experience-api.ts:
// Initiate WebAuthn authentication, returns challenge options
async function startWebAuthnAuth(): Promise<WebAuthnOptions>
// Verify WebAuthn assertion response, returns verificationId
async function verifyWebAuthnAuth(credential: PublicKeyCredential): Promise<string>
// Initiate WebAuthn registration, returns creation options
async function startWebAuthnRegistration(): Promise<WebAuthnCreationOptions>
// Verify WebAuthn registration attestation
async function verifyWebAuthnRegistration(credential: PublicKeyCredential): Promise<string>
4.3 Sign-In Flow Changes
After password verification, when Logto returns an MFA challenge:
- Read effective auth policy from
/api/configor/{slug}/mfa-policy - Read
mfa_method_preferencefrom the MFA challenge context (or default to passkey ifpasskey_mode: preferred) - Route to:
mfaWebauthnmode if preference is passkeymfaVerifymode (existing) if preference is TOTPmfaMethodPickerif no preference set and both are enrolled
- Each mode shows a link to switch to the other method
4.4 Post-Sign-In Nudge
After successful sign-in, if:
- Passkey is enabled in the effective policy
- User has no passkeys enrolled
- Nudge hasn't been dismissed in the last 30 days
Show a banner at the top of the platform UI (not in the sign-in UI — this happens after redirect back to the SPA).
4.5 WebAuthn Browser API
Use @simplewebauthn/browser for the client-side WebAuthn ceremony:
startRegistration(options)— wrapsnavigator.credentials.create()startAuthentication(options)— wrapsnavigator.credentials.get()
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: trueshown asmfaMode: 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:
passkey_mode: requiredalready means passkey is the only accepted factor- 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)
- The policy model already supports this: a future
auth_mode: passwordlesssetting could skip the password step entirely - 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:latestsatisfies this @simplewebauthn/browsernpm package for sign-in UI and platform UI- No Java WebAuthn libraries needed (all ceremonies handled by Logto)