MFA, Passkeys & Security Events
TOTP (MFA)
Six-digit codes, 30-second period, issuer label Camel Accounts. Once enabled, every password login requires it (passkey and social logins bypass TOTP by design).
POST /auth/mfa/setup
Auth: session · No body.
curl -s -b cookies.txt -X POST "$ISSUER/auth/mfa/setup"{
"success": true,
"data": {
"secret": "JBSWY3DPEHPK3PXP",
"uri": "otpauth://totp/Camel%20Accounts:amina%40example.com?secret=JBSWY3DPEHPK3PXP&issuer=Camel%20Accounts&algorithm=SHA1&digits=6&period=30"
}
}Render uri as a QR code for the authenticator app. The secret is stored but MFA is not active yet.
POST /auth/mfa/enable
Confirm a live code to activate:
{ "code": "123456" }→ { "success": true, "message": "mfa enabled" }
Errors (400): code is required · invalid mfa code · mfa not set up — call setup first · mfa already enabled.
POST /auth/mfa/disable
{ "code": "123456" }→ { "success": true, "message": "mfa disabled" } (400 on bad code or MFA not enabled).
POST /auth/mfa/verify
Completes an MFA-challenged login. Auth: none (this endpoint creates the session).
{ "mfa_token": "<from /auth/login>", "code": "123456" }Success sets the session cookie and returns the user object, same shape as /auth/me:
{ "success": true, "data": { "id": "…", "email": "…", "email_verified": true, "phone_verified": false } }Errors: 400 mfa_token and code are required · 401 invalid mfa code (bad code or expired token).
Full login sequence with MFA:
# 1 → challenge
curl -s -c cookies.txt -X POST "$ISSUER/auth/login" \
-H "Content-Type: application/json" \
-d '{"identifier":"amina@example.com","password":"correct-horse"}'
# {"success":true,"data":{"mfa_required":true,"mfa_token":"…","user_id":"…"}}
# 2 → finish
curl -s -b cookies.txt -c cookies.txt -X POST "$ISSUER/auth/mfa/verify" \
-H "Content-Type: application/json" \
-d '{"mfa_token":"…","code":"987654"}'Passkeys (WebAuthn/FIDO2)
A successful passkey assertion is complete authentication — it replaces password AND TOTP. Available only when the deployment configures WebAuthn (WEBAUTHN_RPID/WEBAUTHN_ORIGIN, derived from the issuer by default).
Ceremony challenges are single-use with a 3-minute TTL and travel in an opaque X-Passkey-Token response header which you echo back on the matching finish call. The finish request bodies are raw browser WebAuthn JSON — not envelope-wrapped; pass through exactly what navigator.credentials produced.
Registration ceremony
// 1. Begin (session cookie required)
const begin = await fetch(`${ISSUER}/auth/passkeys/begin-registration`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Amina's laptop" }),
credentials: "include",
});
const options = (await begin.json()).data; // PublicKeyCredentialCreationOptions
const token = begin.headers.get("X-Passkey-Token");
// 2. Let the authenticator do its thing
const credential = await navigator.credentials.create({ publicKey: options });
// 3. Finish — RAW browser JSON + header
const done = await fetch(`${ISSUER}/auth/passkeys/finish-registration`, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Passkey-Token": token },
body: JSON.stringify(credential), // serialize per WebAuthn L3
credentials: "include",
});
// 201 → { "id": "<credentialId>", "created_at": "2026-08-26T09:00:00Z" }Errors: 400 missing X-Passkey-Token header · 401 passkey challenge expired or already used · 400 could not verify passkey.
List & remove
curl -s -b cookies.txt "$ISSUER/auth/passkeys"{
"success": true,
"data": [
{ "id": "abc123…", "name": "Amina's laptop", "created_at": "2026-08-01T10:00:00Z", "last_used_at": "2026-08-26T07:12:44Z" }
]
}curl -s -b cookies.txt -X DELETE "$ISSUER/auth/passkeys/abc123…"
# { "success": true, "message": "passkey removed" } (404 "passkey not found")Login ceremony
// 1. Begin — identifier only, no password involved
const begin = await fetch(`${ISSUER}/auth/passkeys/login/begin`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identifier: "amina@example.com" }),
});
const options = (await begin.json()).data; // PublicKeyCredentialRequestOptions
const token = begin.headers.get("X-Passkey-Token");
// 2. Assert locally
const assertion = await navigator.credentials.get({ publicKey: options });
// 3. Finish — success issues a full session cookie + user object
await fetch(`${ISSUER}/auth/passkeys/login/finish`, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Passkey-Token": token },
body: JSON.stringify(assertion),
});
// 200 → { "success": true, "data": { …user object… } }Notes:
login/begincounts against the otp-send rate limit; unknown users or accounts without passkeys get401 no passkey login available for this account.- Failures at
finish:401 passkey verification failed·401 passkey challenge expired or already used. - Challenges cannot be replayed — a captured finish call is worthless after first use.
GET /auth/security-events
The user's activity feed, newest first, capped at 100 entries. Auth: session.
curl -s -b cookies.txt "$ISSUER/auth/security-events"{
"success": true,
"data": [
{
"action": "login",
"ip_address": "41.86.176.12",
"detail": "Signed in",
"created_at": "2026-08-26T08:15:31Z"
},
{
"action": "app_connected",
"ip_address": null,
"detail": "Connected Bajeti Web",
"created_at": "2026-08-20T14:22:05Z"
}
]
}| Action | Fired when |
|---|---|
login | Any sign-in (password/TOTP-completed, passkey, social) |
email_verified | Email OTP accepted |
password_reset | Password changed via reset flow |
mfa_enabled / mfa_disabled | TOTP toggled |
passkey_added / passkey_removed | Credential registered/deleted |
app_connected / app_revoked | Consent granted / connection revoked |
Sensitive changes and first-seen-IP logins additionally trigger alert emails to the user.