MFA

Multi-factor authentication in Huudis is per-user, not per-workspace — a user enrols once and the same TOTP / passkey works across every Forjio product they sign into.

This page covers the management API: enrol a device, verify the enrolment code, list active devices, remove a device, and redeem an MFA challenge during sign-in.

For the wire-level MFA-challenge step in the OIDC flow, see OIDC overview → MFA gate. For the user-facing UI walkthrough, see Portal → Security.

Endpoints

Method Path Purpose
POST /v1/mfa/enroll Begin TOTP enrolment — returns secret + QR code
POST /v1/mfa/verify-enrollment Confirm the enrolment with the first 6-digit code
GET /v1/mfa/devices List the calling user's MFA devices
DELETE /v1/mfa/devices/:id Remove a device (still requires another method)
POST /v1/mfa/verify-login Redeem an MFA challenge during sign-in (no session required)

Endpoints under /v1/mfa/enroll, /verify-enrollment, /devices, and /devices/:id require a bearer admin JWT for the user themselves — you can only manage your own factors. There is no admin-side API to enrol an MFA device for someone else.

Start TOTP enrolment

POST /v1/mfa/enroll

Generates a fresh TOTP secret, stores it as pending, and returns the provisioning details. The device shows up in GET /v1/mfa/devices immediately but with verified: false — you must call verify-enrollment within 10 minutes or the row is reaped.

Request body

Field Type Description
label string (≤80) Human-readable hint, e.g., iPhone 1Password. Default Authenticator.

Response201 Created

{
  "data": {
    "deviceId": "mfa_01KPG…",
    "secret": "JBSWY3DPEHPK3PXP",
    "uri": "otpauth://totp/Huudis:adi%40forjio.com?secret=JBSWY3DPEHPK3PXP&issuer=Huudis&algorithm=SHA1&digits=6&period=30",
    "qrCodeSvg": "<svg>…</svg>",
    "backupCodes": [
      "9HPM-K2NQ-4R7T",
      "X8VC-3MDP-LJYR",
      "..."
    ]
  }
}

The secret, uri, qrCodeSvg, and backupCodes are shown exactly once on this response. They are not retrievable afterwards. Render the QR for the user, save the backup codes (the user should download them), and proceed to verify.

backupCodes is 10 single-use recovery codes. Each one can be used in place of a TOTP code during sign-in, then is consumed.

Verify enrolment

POST /v1/mfa/verify-enrollment

Confirms the user has the TOTP secret loaded in their authenticator by submitting the current code.

Request body

Field Type Required Description
deviceId string (mfa_…) yes The device returned by enrol.
totpCode string (6–10 chars) yes Current 6-digit code from the authenticator app.

Response200 OK. { "verified": true }. The device flips to verified: true and MFA becomes mandatory for the user's subsequent sign-ins.

Errors

Status error.code When
400 INVALID_CODE Code doesn't match (clock skew, typo, wrong device).
400 ALREADY_VERIFIED The device is already verified.
404 DEVICE_NOT_FOUND No device with that ID for the calling user.
410 ENROLLMENT_EXPIRED Took longer than 10 minutes; restart at /enroll.

List MFA devices

GET /v1/mfa/devices

Returns the calling user's MFA devices. Pending (unverified) devices are included until they expire.

Response

{
  "data": [
    {
      "id": "mfa_01KPG…",
      "method": "totp",
      "label": "iPhone 1Password",
      "verified": true,
      "createdAt": "2025-12-04T11:00:00.000Z",
      "lastUsedAt": "2026-05-12T08:55:00.000Z",
      "backupCodesRemaining": 7
    }
  ]
}

backupCodesRemaining decreases as the user redeems them at sign-in. When it reaches 0, prompt the user to re-enrol or generate fresh codes (re-enrolling generates a new set).

Remove a device

DELETE /v1/mfa/devices/:id

Removes the device. The user keeps MFA enabled if any other verified device remains; otherwise MFA becomes optional again.

200 OK{ "removed": true }.

Errors

Status error.code When
404 DEVICE_NOT_FOUND The device isn't yours or doesn't exist.
400 LAST_FACTOR_LOCKED Removing this device would leave a user under an forjio:MfaRequired IAM policy without a way to sign in. Lift the policy first.

Redeem an MFA challenge

POST /v1/mfa/verify-login

This is the one MFA endpoint that does not require a session — the user isn't signed in yet; they're mid-challenge from /v1/auth/login or /v1/oidc/token. The caller (typically the dashboard or an OIDC client) holds an mfaChallengeToken returned alongside the partial sign-in and presents the user's code here.

Request body

Field Type Required Description
mfaChallengeToken string (20–200) yes The token from the partial sign-in response. Single-use; 5-minute TTL.
code string (6–20) yes Either a TOTP code or one of the user's backup codes (with or without dashes).

Response200 OK

{
  "data": {
    "userId": "usr_01KPG…",
    "sessionId": "sess_01KPG…",
    "expiresAt": "2026-05-13T07:00:00.000Z",
    "mfaVerified": true
  }
}

A Set-Cookie: huudis_session=… header accompanies the response on the same domain — the browser session is now MFA-verified.

Errors

Status error.code When
400 INVALID_CODE TOTP code doesn't match any device, and isn't a valid backup code.
401 CHALLENGE_EXPIRED The challenge token is older than 5 minutes. Restart at /v1/auth/login.
401 CHALLENGE_USED The challenge token has already been redeemed. Single-use.

The MFA device object

Field Type Description
id string (mfa_…) Device ID.
method totp Currently only TOTP. WebAuthn is on the roadmap.
label string Operator-provided hint.
verified boolean Whether enrolment was confirmed. Unverified devices are not honored at sign-in and get reaped after 10 minutes.
createdAt, lastUsedAt ISO 8601
backupCodesRemaining integer How many backup codes are still unused. Starts at 10.

The TOTP secret, the qrCodeSvg, and the backupCodes are returned only on the enrol response. Lost? Re-enrol.

Backup codes

Backup codes are single-use 12-character recovery strings (formatted as 4-4-4 with dashes). Each can be redeemed in place of a TOTP code at sign-in. Once used, they're consumed.

If a user runs out, they can re-enrol the same device and Huudis issues a fresh set of 10. There's no "generate codes" endpoint — re-enrol is intentional friction so users notice they've burned through them.

Policy gating

The IAM policy condition forjio:MfaPresent requires the calling session to have passed MFA. Pair with a workspace-wide deny to force MFA for sensitive actions:

{
  "Effect": "Deny",
  "Action": "huudis:*",
  "Resource": "*",
  "Condition": {
    "Bool": { "forjio:MfaPresent": "false" }
  }
}

Attach to a group that contains all admins. Members without MFA enrolled cannot perform any IAM action until they enrol.

Events

Event type Fires on
huudis.mfa.enrolled.v1 POST /v1/mfa/verify-enrollment succeeds. Reserved — not yet emitted in v1.
huudis.mfa.removed.v1 A device is deleted. Reserved — not yet emitted.

The audit log carries mfa.enrolled, mfa.removed, mfa.verified, and mfa.backup_code_used.

Next