Base 3:16
Guides

Second factors

Authenticator apps, emailed codes and text messages — enrolling them, recovering from a lost one, and where each is enforced.

A user can hold an authenticator app (TOTP), a code sent to their inbox, or a code sent by text message. Each is off until somebody turns one on for themselves — unless the environment's auth policy sets mfaRequirement: "REQUIRED", in which case anybody without one enrols one as part of signing in (see When a factor is required). A factor is never imposed on an account in a way its owner cannot complete.

Which to offer

Offer both. The app is stronger, and the emailed code is the one people who will not install anything actually use — a factor somebody turns on beats a stronger one they decline.

Be honest with yourself about what the emailed code buys. It is only as strong as the mailbox, and if that is the same address that receives password resets it protects against a stolen password and very little else. It is still a great deal better than nothing.

Enrolling

<TwoFactorManager /> in @base316/react offers both and handles the whole flow, drawing the authenticator's QR code in the browser (the secret is never sent anywhere to be turned into a picture) with the key underneath for anyone who cannot scan it. On the API:

// An authenticator app: returns a secret and an otpauth:// URI to draw as a QR.
const { factorId, secret, uri } = await client.auth.mfa.enroll(sessionToken);

// A code to the inbox: the first code is sent immediately.
const { factorId, destinationHint } = await client.auth.mfa.enrollEmail(sessionToken);

// A code by text message, to the account's number or one you pass.
const { factorId, destinationHint } = await client.auth.mfa.enrollSms(sessionToken, { phone });

A text message goes out from Base316's number unless the environment has its own Twilio account, and in production counts against your plan's monthly allowance. See Text messages and phone numbers.

Either way, enrolling does not activate. The factor gates nothing until a code from it comes back:

const { recoveryCodes } = await client.auth.mfa.confirm(sessionToken, {
	factorId,
	code,
});

That two-step is what stops somebody locking themselves out with a QR code that never scanned.

The authenticator secret is returned by enroll and nowhere else. Base316 keeps it encrypted at rest (AES-256-GCM), never includes it in a data export or the dashboard, and only decrypts it to check a code.

The recovery codes are returned once and stored only as digests. Show them, say plainly that they will not be shown again, and mean it.

The address an emailed factor uses is fixed when it is enrolled. It does not follow the account's email afterwards — otherwise taking over an address would silently take over the second factor protecting it.

Signing in

A sign-in that owes a factor returns a challenge instead of a session:

const result = await client.auth.signIn({ email, password });

if (needsSecondFactor(result)) {
	// result.factors says what can answer it — an authenticator box and
	// "check your email" are different screens. For a deliverable factor the
	// code is already sent by the time this returns.
	const { token } = await client.auth.verifyMfa({
		challengeToken: result.challengeToken,
		code,
	});
}

Render from result.factors rather than assuming. A page that guesses tells half your users to open an app they never installed.

challengeToken is what proves the password step happened. Treat it as a credential: it is opaque, answers once, and expires ten minutes after the sign-in that made it. Hold it in memory for the length of the challenge rather than persisting it.

This replaced a userId parameter, and the old shape no longer works. A user id is not a secret — it appears in team member lists, in the OIDC sub claim and in anything you provision over SCIM — so accepting one as evidence that the first factor had been passed meant a second factor on its own could open an account. Upgrade the SDK and pass result.challengeToken.

If the code never arrives, client.auth.mfa.resend({ challengeToken }) sends another. It takes no session, because at that point there is none. Sending a new code retires the old one, and asking repeatedly produces one email every thirty seconds however often the button is pressed.

Guesses are counted against the sign-in rather than against the code, so resending does not hand out a fresh budget of them. After ten wrong answers the challenge is spent and the person starts again from the password.

When a factor is required

With mfaRequirement: "REQUIRED" on the environment's auth policy, every sign-in owes a second factor — including from somebody who has none yet, like a user who has just signed up or accepted an invitation. Their sign-in returns a challenge with enroll: true, no factors, and the methods they may enrol:

const result = await client.auth.signIn({ email, password });

if (needsEnrollment(result)) {
	// result.enrollMethods: "TOTP" always; "EMAIL_OTP" where the account has an
	// address; "SMS_OTP" where the environment can send text messages.
	const started = await client.auth.startRequiredEnrollment({
		challengeToken: result.challengeToken,
		method: "TOTP",
	});
	// TOTP: started.secret, started.uri (otpauth://) and started.qrSvg to show
	// as an image. EMAIL_OTP / SMS_OTP: the first code is sent;
	// started.destinationHint says where.

	const { token, recoveryCodes } = await client.auth.completeRequiredEnrollment({
		challengeToken: result.challengeToken,
		factorId: started.factorId,
		code,
	});
	// Show recoveryCodes now — they are not returned again — then use `token`.
}

/v1/auth/magic-link/verify and /v1/auth/phone/verify answer the same way. <SignIn /> and <SignUp /> in @base316/react do all of this, and <SecondFactorStep /> does it after a first step of your own.

The hosted sign-in pages do it too, with no code of yours: an email code by default, a text message where the environment can send one, an authenticator for an account with neither. A mistyped code keeps the same step on screen (and a reload does not send another code or mint another key). Once the code is right, the recovery codes are shown once, on their own page, and the person moves on to your application only when they press I have saved these codes.

Both calls take the challenge, never a session, and the challenge keeps its rules: single use, ten minutes, ten wrong codes in total. Starting again replaces an authenticator secret, and for email resends the code — at most once every thirty seconds. Completing mints the session exactly as verifyMfa does, under the environment's session lifetime and cap, with MFA_ENROLLED, MFA_VERIFIED and SIGNED_IN in the audit log.

An account that already has a factor is refused (CONFLICT, reason: "ALREADY_ENROLLED") and answers its challenge with verifyMfa. A password on its own is never enough to enrol a second authenticator around the one an account already has.

Where a factor is enforced

Signing in withSecond factor asked for
PasswordYes
Magic linkYes
Text messageYes
Social sign-inYes
PasskeyNo
Enterprise SSONo

Both exceptions are decisions rather than gaps.

A passkey is already possession of a registered authenticator, usually behind a biometric or device PIN, and it cannot be phished. Asking for six digits on top adds friction to the strongest thing on the list.

Enterprise SSO exists so the customer's identity provider is the only door. MFA policy there belongs to the enterprise that bought it, and adding one here would sit on top of whatever they already enforce.

If you need a factor on top of an SSO sign-in for a particular customer, that is a per-connection option rather than a default — say so and it can be built.

Losing a factor

A recovery code signs somebody in once and is then spent. When they run out, an administrator can remove the factor from the user's Security panel in the dashboard, which shows which factor the account holds and — for an emailed one — a masked hint of where its codes go. That last detail is usually the actual question when somebody calls to say the codes are not arriving.

On this page