Multi-Factor Authentication
BuildPad DaaS supports time-based one-time passwords (TOTP) from an authenticator app. The flow uses the native MFA support in Supabase Auth, so the access token carries the assurance level that the server reads.
There are two assurance levels:
| Level | Meaning |
|---|---|
aal1 | The user proved one factor. A password sign-in gives this level. |
aal2 | The user also proved a second factor. |
A user who enrolls no factor stays at aal1, and nothing changes for them.
After a user verifies a factor, every request from that user must reach aal2.
Enroll a factor
Enrollment has two calls. The first returns the secret to show to the user. The second confirms that the user can produce a code from it.
-
Call
POST /api/auth/mfa/enroll. The optionalfriendly_namefield names the factor.{ "data": { "factor_id": "...", "qr_code": "data:image/svg+xml;...", "secret": "JBSW...", "uri": "otpauth://totp/..." } }Show
qr_codefor the user to scan. Showsecretas well, because some users enter the key by hand. -
Call
POST /api/auth/mfa/verifywith the factor and the six-digit code:{ "factor_id": "...", "code": "123456" }The response carries a new session at
aal2:{ "data": { "access_token": "...", "refresh_token": "...", "expires": 3600000 } }Replace the stored tokens with these.
expiresis in milliseconds.
The factor is unverified until step 2 succeeds. An unverified factor does not
trigger the gate below.
Factor management is bound to the session of the user. An administrator cannot enroll or remove a factor for someone else.
Step up an existing session
A user who signs in with a password holds an aal1 session. If they have a
verified factor, requests answer 403:
{
"error": "Multi-factor authentication required",
"code": "MFA_REQUIRED",
"current_aal": "aal1",
"required_aal": "aal2"
}Prompt for the one-time code and call POST /api/auth/mfa/verify with it. Use
the tokens from the response. Do not sign the user out — the password step is
already complete, and a full sign-in is not necessary.
POST /api/auth/login also returns an mfa_required hint, so a client can ask
for the code directly after the password.
Treat 403 with code: "MFA_REQUIRED" as “ask for a code”, not as “the
session is invalid”. A client that clears the session on any 403 sends the
user back to a password screen that cannot fix the problem.
Endpoints
| Endpoint | Purpose |
|---|---|
GET /api/auth/mfa/factors | List the factors of the current user, with id, status, factor_type, friendly_name, and created_at. |
POST /api/auth/mfa/enroll | Start enrollment. Returns factor_id, qr_code, secret, and uri. |
POST /api/auth/mfa/verify | Confirm a code. Returns a session at aal2. |
POST /api/auth/mfa/unenroll | Remove a factor. Takes factor_id. |
GET /api/users/[id]/mfa-status | Read whether another user has a verified factor. Administrators only. |
GET /api/auth/mfa/factors, POST /api/auth/mfa/enroll, and
POST /api/auth/mfa/verify accept an aal1 session. Without that, a user who
must enroll could not reach the endpoints that let them enroll.
Require MFA for every user
Set MFA_FORCE_ENABLED=true to make MFA mandatory. Then:
- A user with no verified factor is blocked from every route except the enroll and verify endpoints.
- The login page shows the enrollment step directly after the password.
POST /api/auth/loginreportsmfa_enrollment_required.- A user cannot remove their last factor. The API rejects the call. This is not only a hidden button.
docker-compose.yml does not list MFA_FORCE_ENABLED. Add the variable to
your own environment. It reads at runtime, so a change needs a restart but not
a new image.
Static tokens and SSO
Static tokens are exempt. A static token (daas_users.token)
authenticates without a session, so it carries no assurance level. A static
token is not blocked, even under MFA_FORCE_ENABLED. Use one for service and
automation accounts under this policy, and treat every issued static token as a
standing exemption.
SSO auto-login is refused for enrolled users. The BuildPad SSO handoff returns an error when the account has a verified factor. Completing it would step around the second factor that the user asked for. Those users sign in with a password, and then complete the code step.
Error codes
| Code | Status | Meaning |
|---|---|---|
MFA_REQUIRED | 403 | The session is aal1 and the user has a verified factor. Prompt for a code. |
MFA_REQUIRED | 403 | Under MFA_FORCE_ENABLED, the user has no factor. Send them to enrollment. |
Both cases use the same code, because both are answered the same way: the user
must reach aal2 before the request can proceed. The login response tells the
two apart with mfa_required and mfa_enrollment_required.