Product
MFA
TOTP, WebAuthn, recovery codes, session elevation, and MFA evidence.
Cairn Identity implements TOTP, one-use recovery codes, and WebAuthn/passkey MFA for browser sessions. Password login accepts any active second factor configured for the user.
TOTP Storage
TOTP secrets are generated server-side and encrypted before persistence:
- API requires
CAIRN_KEY_ENCRYPTION_KEYfor TOTP enrollment and verification. mfa_credentials.secret_metadatastores AES-256-GCM ciphertext and nonce, not a plaintext secret.- Additional authenticated data binds each encrypted secret to
organization_idanduser_id. - Pending enrollments are stored with
status=pending; confirmation flips the same row tostatus=active.
Recovery Codes
TOTP confirmation returns 10 one-use recovery codes. The API stores only SHA-256 hashes as MfaKind::RecoveryCode rows:
- Recovery codes are returned only once, in the TOTP confirmation or regeneration response.
- Confirming a new TOTP enrollment or regenerating recovery codes revokes older active recovery-code rows before storing the new code set.
- A successful recovery-code login marks that code
status=consumedand setslast_used_at. - Revoking the last active TOTP/passkey second factor revokes active recovery codes, so stale fallback codes cannot become valid again after a future enrollment.
- Recovery-code sessions use
amr=["pwd","recovery"]andacr="urn:cairn:acr:password+recovery_code".
WebAuthn / Passkeys
Passkey ceremonies use webauthn-rs passkey flows with user verification required:
CAIRN_PUBLIC_WEB_ORIGINis the WebAuthn relying party origin; the relying party ID is derived from that origin host.- Registration and authentication state is serialized only into the server-side
webauthn_challengestable. - Challenges are scoped to
organization_id,user_id, and ceremony kind; they expire after 5 minutes and are consumed once under a row lock. - Active passkeys are stored as WebAuthn public credential material in
mfa_credentials.secret_metadata. secret_metadata.credential_idstores a stable base64url credential ID, and a partial unique index prevents duplicate active passkeys in the same organization.- Successful passkey sessions use
amr=["pwd","mfa","user"]andacr="urn:cairn:acr:password+webauthn".
Device Management
Current users can review and revoke their enrolled TOTP and passkey credentials:
- The list endpoint returns only browser-safe metadata:
id,kind,label,status,created_at,last_used_at, and the active recovery-code count. - Destructive credential revocation requires the current browser session to have completed TOTP, WebAuthn, or recovery-code MFA within the last 15 minutes.
- Recovery-code regeneration requires the same recent MFA proof and at least one active TOTP/passkey credential.
- Self-service password change requires the same recent MFA proof when the user has an active TOTP or passkey credential, then rotates the browser session without extending the original MFA authentication time.
- The account UI opens a reauthentication prompt when revocation or recovery-code regeneration needs fresh proof; successful reauthentication rotates the browser session before retrying the action.
- TOTP and passkey revocation sets
secret_metadata.statustorevokedrather than deleting the row, preserving auditability without allowing future use. - Recovery codes are not listed individually and are never returned after initial generation.
- Revoking a credential writes
mfa.credential_revokedto the audit log. - Regenerating recovery codes writes
mfa.recovery_codes_regeneratedto the audit log.
Endpoints
-
GET /api/v1/session/mfa/credentials- Requires a valid browser session.
- Returns
{ "credentials": [...], "recovery_code_count": 10 }.
-
DELETE /api/v1/session/mfa/credentials/{credential_id}- Requires a valid browser session, CSRF token, and recent MFA proof from the current session.
- Revokes a current-user TOTP or passkey credential.
- If no active TOTP/passkey credentials remain, active recovery codes are also revoked.
-
POST /api/v1/session/mfa/recovery-codes/regenerate- Requires a valid browser session, CSRF token, recent MFA proof from the current session, and at least one active TOTP/passkey credential.
- Revokes active recovery-code rows and stores a fresh set.
- Returns
{ "status": "regenerated", "recovery_codes": [...] }.
-
POST /api/v1/session/reauthenticate- Requires a valid browser session and CSRF token.
- Body accepts
password, optionalmfa_code/totp_code/recovery_code, or awebauthn_challenge_idpluswebauthn_credential. - Returns
{ "status": "mfa_required", "methods": [...], "webauthn": { ... } }when the password is valid but configured MFA is still needed. - A successful recovery-code reauthentication consumes that recovery code.
- On success, revokes the previous browser session, sets a new
cairn_sessioncookie, and returns{ "status": "reauthenticated", "acr": "...", "amr": [...] }.
-
POST /api/v1/session/mfa/totp/start- Requires a valid browser session and CSRF token.
- Body:
{ "label": "Authenticator app" }. - Returns
credential_id,otpauth_url, andsecret_base32.
-
POST /api/v1/session/mfa/totp/confirm- Requires a valid browser session and CSRF token.
- Body:
{ "credential_id": "...", "code": "123456" }. - Validates the pending secret, activates the credential, and returns one-use recovery codes.
-
POST /api/v1/session/mfa/webauthn/start- Requires a valid browser session and CSRF token.
- Body:
{ "label": "Work laptop" }. - Returns
challenge_idand WebAuthn creationoptionsfornavigator.credentials.create.
-
POST /api/v1/session/mfa/webauthn/finish- Requires a valid browser session and CSRF token.
- Body:
{ "challenge_id": "...", "label": "Work laptop", "credential": { ... } }. - Consumes the pending challenge, verifies the browser credential, rejects duplicate active credential IDs, and stores the passkey.
-
POST /api/v1/session/login- Body accepts optional
mfa_code,totp_code,recovery_code, or awebauthn_challenge_idpluswebauthn_credential. - If the password is valid and any active second factor exists but no factor is supplied, the response is
{ "status": "mfa_required", "methods": [...], "webauthn": { "challenge_id": "...", "options": { ... } } }when passkeys are active. - If the TOTP code is valid, the browser session is created with
amr=["pwd","otp"]andacr="urn:cairn:acr:password+totp". - If a recovery code is valid, the code is consumed and the browser session is created with
amr=["pwd","recovery"]andacr="urn:cairn:acr:password+recovery_code". - If a passkey assertion is valid, the browser session is created with
amr=["pwd","mfa","user"]andacr="urn:cairn:acr:password+webauthn".
- Body accepts optional
Tests
Coverage includes:
- TOTP generation and verification in
crates/authn. - API metadata parsing and AAD binding tests.
- API recovery-code hashing/generation tests.
- API recent-MFA proof tests for destructive MFA credential revocation.
- API password-change tests for recent-MFA enforcement when a second factor is enrolled.
- API session-construction and reauthentication rate-limit key tests.
- Playwright account-page coverage for password-change reauthentication, stale-proof MFA credential revocation, inline reauthentication, retry, and recovery-code cleanup after the last second factor is removed.
- Playwright WebAuthn coverage for passkey enrollment and login with a Chromium virtual authenticator.
- Real-Postgres auth-session rotation invariant coverage.
- Real-Postgres recovery-code replacement invariant coverage.
- WebAuthn relying party origin derivation in
crates/authn. - Real-Postgres MFA credential scoping, TOTP metadata update, recovery-code consumption, MFA credential revocation, active recovery-code cleanup, WebAuthn challenge one-time consumption, WebAuthn challenge expiry, and tenant-scoped passkey credential ID lookup in
postgres_protocol_invariants.