API Reference
MFA, Passkeys & Security Events

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/begin counts against the otp-send rate limit; unknown users or accounts without passkeys get 401 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"
    }
  ]
}
ActionFired when
loginAny sign-in (password/TOTP-completed, passkey, social)
email_verifiedEmail OTP accepted
password_resetPassword changed via reset flow
mfa_enabled / mfa_disabledTOTP toggled
passkey_added / passkey_removedCredential registered/deleted
app_connected / app_revokedConsent granted / connection revoked

Sensitive changes and first-seen-IP logins additionally trigger alert emails to the user.