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. |
Response — 201 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. |
Response — 200 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). |
Response — 200 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
- OIDC overview → MFA gate — how MFA fits into the sign-in flow.
- Policies — condition keys that gate on MFA.
- Account — the calling user's profile, including
mfaEnabled.