Multi-factor authentication
Enrolling a factor, the challenge it produces at sign-in, and recovery.
MFA has two halves that are easy to conflate. Enrolment is something a signed-in user does to their own account. The challenge is what sign-in produces afterwards, for anyone who has a factor.
You build them as separate screens, and only one of them is in the sign-in path.
The challenge side
Once a user has an enabled factor, signIn stops returning a session directly:
const { data, error } = await nulvless.auth.signIn({ email, password });
if (error) return showError();
if (data.challenge) return goToVerify(data.challenge);
// no challenge — signed inThe platform picks which factor to challenge with, in a fixed preference order:
totp1stAn authenticator app. Chosen whenever the user has one.
sms_otp2ndA code by SMS.
email_otp3rdA code by email.
recovery_codeslastOnly when nothing else is available.
challenge.preferred is the one chosen; challenge.factors lists the
alternatives the user actually holds. That second field is what a "try another
way" link is built from.
Switching method mid-flow
A user whose authenticator is unavailable does not restart sign-in — the challenge already in flight is moved to a different method:
await nulvless.auth.challenges.change({
token: challenge.token,
type: 'email_otp',
});The field is type, not method.
Finishing
Same call as every other challenge, which is why one component serves all of them:
const { data } = await nulvless.auth.verifyChallenge({
token: challenge.token,
code,
});A recovery code goes in the same code field — there is no separate call for
it.
The enrolment side
Enrolment is two steps on purpose: the secret is handed over first, and the factor only becomes real once the user proves their device produces the right code.
Start it
const { data } = await nulvless.auth.factors.enroll({
type: 'totp',
name: 'My authenticator',
});
// data.secret → manual entry
// data.url → render as a QR code
// data.token → carry to enable()typeMFAMethodRequiredtotpsms_otpemail_otprecovery_codesnamestringOptionalShown in the user's factor list.
noncestringOptionalSudo nonce — see below.
secret and url come back only for TOTP. url is a provisioning URL, so you
can turn it straight into a QR code without assembling one yourself.
Confirm it
await nulvless.auth.factors.enable({
token: data.token,
code: firstCodeFromTheApp,
});Until this succeeds the factor does not exist. An abandoned enrolment leaves the account exactly as it was — no half-enabled state to clean up.
Listing what a user has
const { data: factors } = await nulvless.auth.factors.list();Each entry carries id, type, name, status (configured or
not_configured), and created_at. The id is what unenroll takes:
const totp = factors.find((factor) => factor.type === 'totp');
if (totp?.id) await nulvless.auth.factors.unenroll({ id: totp.id });A user can hold several authenticator apps and SMS factors — up to 10 combined,
which an environment can lower in the console's sign-in settings
(Factors per user, in the MFA section). Each SMS factor has its own phone, which
may differ from the user's. Email and recovery codes stay one each. During
sign-in, a totp challenge accepts a code from any of the user's apps, and
challenges.change({ token, type: 'sms_otp', factor_id }) picks which phone
receives an SMS.
Recovery codes
const { data } = await nulvless.auth.factors.getRecoveryCodes();
// data.codes → string[]
await nulvless.auth.factors.deleteRecoveryCodes();Show them once, at enrolment, and make the user acknowledge them. They are the
only way back in for someone who loses their device — the alternative is a
support path where an admin removes the factor with
auth.admin.users.removeFactor, and the
platform will not second-guess that call.
Everything destructive needs sudo
enroll, unenroll, getRecoveryCodes and deleteRecoveryCodes are all
sudo-protected. Only enable is not — it is already proving possession of the
device.
auth.sessions.sudo() sends the code either way. Spend it on the session with
sudoVerify, or on this one call:
await nulvless.auth.factors.unenroll({ id, nonce: code });The nonce is that code. It goes in the params, not the options.
This is the point of sudo on MFA management: a stolen session should not be able to quietly remove the second factor protecting the account. See Sudo mode.
When the environment requires MFA
An environment can be configured so that MFA is not optional. Two settings matter:
Both live in the console, under Authentications.
Require multi-factor authentication (MFA)Sessions that never used a second factor can be rejected on request.
Authentication methodsWhich factor types this environment allows at all.
Requiring MFA does not enrol anyone for you. Your product still has to walk a
user without a factor through enrolment — typically by checking
factors.list() after sign-in and routing to a setup screen when it comes back
empty.