identity-mfa (registration/management) and identity-session (login).@simplewebauthn/server to generate/verify challenges, so the@simplewebauthn/browser:startRegistration(options) — wraps navigator.credentials.create()startAuthentication(options) — wraps navigator.credentials.get()attestation_response /assertion_response).Requires HTTPS (or localhost) — WebAuthn will not work over plain HTTP in any other host.
POST /identity-mfasidentity-mfa creation call.{
"type": "passkey",
"device_id": "a stable, client-generated identifier for this device/browser",
"device_name": "e.g. \"Chrome on Sam's MacBook\" (optional, shown in device lists)"
}device_id is required for passkeys. It's how we tell one registered device/browser apartcrypto.randomUUID()) and persist it (e.g. localStorage) — dodevice_name is optional but recommended for a good "manage your passkeys" UX.201):{
"id": "01J...",
"identity_id": "01J...",
"type": "passkey",
"status": "pending",
"contexts": ["login"],
"system_meta": { "passkey": { "device_id": "...", "device_name": "...", "device_type": null } },
"created": 1234567890,
"updated": 1234567890,
"provisioning": {
/* PublicKeyCredentialCreationOptionsJSON — pass this directly to startRegistration() */
},
"backup_codes": null
}id from the response — you'll need it in step 2 (as the :id path param).PATCH /identity-mfas/:id{
"attestation_response": "<the object returned by startRegistration()>"
}200) is the same normalized shape as step 1, now with status: "active".MFA_PASSKEY_CHALLENGE_TTL_MS (5 minutes by default) —PATCH /identity-mfas/:id{ "status": "inactive" }403) if it's the identity's last active MFA method usable forPOST /identity-sessions{ "email": "...", "password": "..." }mfa_required is false, login is complete — nothing else to do.mfa_required is true, the response looks like:{
"mfa_required": true,
"session_id": "01J...",
"session_token": "...",
"expires_at": 1234567890,
"next_steps": [
{ "identity_mfa_id": "01J...", "type": "passkey" },
{ "identity_mfa_id": "01J...", "type": "email-mobile", "masked_email": "j***@example.com" }
]
}next_steps tells you which MFA methods are available. If any entry has "type": "passkey",identity_mfa_id for passkeys, since the browserPOST /session-otps{ "session_token": "<from step 1>", "type": "passkey" }201):{
"type": "passkey",
"options": {
/* PublicKeyCredentialRequestOptionsJSON — pass this directly to startAuthentication() */
}
}404.Note: this is the same endpoint used for requesting an email OTP — pass identity_mfa_id
instead oftypefor that flow. The two are mutually exclusive per call.
PATCH /identity-sessions{
"session_token": "<from step 1>",
"assertion_response": "<the object returned by startAuthentication()>"
}identity_mfa_id. Response (200) is the standard completed-session shape:{
"mfa_required": false,
"session_token": "...",
"session_id": "01J...",
"identity_id": "01J...",
"expires_at": 1234567890,
"identity": { "name": "...", "primary_email": "...", "status": "active" }
}session_token replaces the pending one from step 1 — use it going forward exactly like theMFA_PASSKEY_CHALLENGE_TTL_MS (5identity-mfa / identity-session errors already handled elsewhere):| Message | When |
|---|---|
A device_id is required to enable passkey MFA | device_id missing on POST /identity-mfas with type: passkey |
This passkey registration request has expired. Please try again | Too long between registration steps 1 and 2 |
A passkey registration response is required to verify this method | attestation_response missing on the verify PATCH |
The passkey registration response is invalid | Browser response failed WebAuthn verification |
This passkey is already registered to an identity | The exact same credential is already registered (e.g. re-registering) |
This is the last active login MFA method for this identity and cannot be deactivated | Trying to remove the identity's only active login MFA method |
Either identity_mfa_id or type is required | Neither provided on POST /session-otps |
No active passkeys are registered for this identity | POST /session-otps with type: passkey but identity has none |
A passkey assertion response is required to complete this login | assertion_response missing on PATCH /identity-sessions |
This passkey is not registered for this identity | Assertion's credential id doesn't match any active passkey for the session's identity |
No pending passkey login challenge found for this session | Step 3 attempted without (or after expiry of) step 2's challenge |
The passkey login response is invalid or has expired | Browser assertion failed WebAuthn verification, or a sign-counter anomaly was detected |
| Step | Method | Path | Auth |
|---|---|---|---|
| Start passkey registration | POST | /identity-mfas | JWT (logged in) |
| Verify passkey registration | PATCH | /identity-mfas/:id | JWT (logged in) |
| Remove a passkey | PATCH | /identity-mfas/:id | JWT (logged in) |
| Password login | POST | /identity-sessions | none |
| Request passkey login options | POST | /session-otps | none (uses session_token) |
| Complete passkey login | PATCH | /identity-sessions | none (uses session_token) |