Multi-factor authentication

Multi-factor authentication (MFA) adds a second step to sign-in: after a user passes a first factor (Google, OTP, or password), they must prove possession of a second factor before they get a full session. You enable and customize MFA per project; a project that leaves it off behaves as before.

Forte supports four second factors plus recovery codes:

  • Authenticator app (TOTP): a 6-digit code from Google Authenticator, 1Password, Authy, or any RFC 6238 app.
  • Passkeys / security keys (WebAuthn): platform authenticators (Touch ID, Windows Hello) or roaming keys (YubiKey).
  • One-time passcode by email: a 6-digit code to the user's verified email.
  • SMS one-time passcode: a 6-digit code to the user's verified phone.
  • Backup codes: one-time recovery codes for when a user loses their other factors.
Client-side API

The flows on this page are part of Forte's client-side API. Call the Users methods from your frontend: responses set the Forte-User-Session-Token cookie automatically. Never call them from code that also holds FORTE_API_TOKEN.

Configure MFA for a project

MFA is configured on the project, alongside your other authentication settings, in the Console or via the CLI. You choose an enforcement mode and which second factors are allowed:

EnforcementBehavior
DISABLEDMFA is off. Sign-in always returns a full session. This is the default.
OPTIONALA user who has a usable second factor is challenged for it; a user who has none signs in normally.
REQUIREDEvery user must pass a second factor. A user who has none is forced to enroll one before their session is granted.

Allowed factors are independent toggles: authenticator app, email OTP, SMS OTP, and WebAuthn. The email and SMS options use the user's existing verified contact, so they need no enrollment.

Valid policy combinations

  • REQUIRED with an authenticator app and/or WebAuthn always provides an independent enrollment path.
  • REQUIRED with contact OTP alone is valid only when passwordless OTP sign-in is blocked and password or Google sign-in remains enabled.
  • OPTIONAL must still enable at least one factor; otherwise, it behaves as though MFA is off.

Forte rejects a policy that could ask a user to use the same passwordless email/SMS channel as both factors, or that disables OTP sign-in without leaving password or Google as a first factor.

Forte keeps the two factors independent automatically. If a user signs in with an email or SMS one-time code, that exact contact method is dropped from their second-factor options and Forte rejects any attempt to use it anyway: so a single inbox can never cover both steps. Other verified contacts stay usable, including a second email address. When a user signs in with a password using an email or phone number as the identifier, any same-channel one-time code goes to exactly that contact: the code always matches the identifier they signed in with. (When the exclusion leaves a user with no usable second factor, an OPTIONAL project signs them in normally and a REQUIRED project makes them enroll a real one.)

Password resets and the inbox that received the link

Completing a password reset already proves control of one inbox, so after a reset the email that received the link is excluded from the MFA step whenever the user has any other usable factor: another verified contact, an authenticator app, a passkey, or backup codes. Forte accepts it only when it's the user's sole factor, so a single-email user can always recover. Encourage users to verify a second contact method (or enroll an authenticator and save backup codes) so account recovery never hinges on one inbox.

You can also stop passwordless OTP from being a sign-in factor while MFA is on: turn on Disable passwordless one-time-code sign-in (the blockOtpFirstFactor setting). With it on, createOtpLogin is refused with 400 OTP_LOGIN_DISABLED_UNDER_MFA, so users sign in with Google or a password and then complete a second factor. One-time codes by email and SMS stay available as a second factor.

A secure default

The strongest common setup is password-login only with enforcement REQUIRED and the authenticator-app and/or WebAuthn factors enabled. That forces every user through a real second factor and keeps the first and second factors on independent channels. If you also offer passwordless OTP login but want to keep it off the MFA path, turn on Disable passwordless one-time-code sign-in rather than relying on contact-based codes for both steps.

Handle MFA during sign-in

Every first-factor flow you already use: googleAuthLoginCallback, registerUser, completeOtpLogin, passwordLogin, and password reset: returns a LoginUserResponse with an optional mfaStatus field. Branch on it:

mfaStatusMeaningWhat the token is
SATISFIED (or absent)No second factor required.A full session token (≈365 days). You're done.
CHALLENGE_REQUIREDThe user must complete a second factor.A short-lived pending token (≈10 minutes).
ENROLLMENT_REQUIREDThe project requires MFA but the user has no usable factor; they must enroll one.A short-lived pending token.

A pending token authenticates only the MFA endpoints needed to finish the second factor: /me/mfa/challenge, /me/mfa/verify, /me/mfa/methods, and logout: plus the enrollment endpoints when its state is ENROLLMENT_REQUIRED. Every request to your deployed app, and every other management endpoint, rejects it with 401 MFA_REQUIRED. Once the second factor is verified, Forte deletes the pending token and issues the full session in its place. See Sessions for the token details.

When mfaStatus is CHALLENGE_REQUIRED, the response also carries availableMfaMethods: the factors this user can use right now, each with a type (TOTP, WEBAUTHN, EMAIL_OTP, SMS_OTP, or BACKUP_CODE) and, for contact-based factors, a contactMethodId plus a maskedTarget like a***@example.com. A user with more than one verified email or phone number gets one entry per eligible contact, so present each entry as its own choice and pass the chosen contactMethodId back as the challenge's targetContactMethodId.

A pending response never includes userObject: everything the second factor protects (custom metadata attributes, contact methods, and so on) stays hidden until MFA is satisfied. You always get the top-level userId, and the full user object arrives on the verifyMfa response once mfaStatus is SATISFIED.

A pending registerUser response additionally carries pendingContactMethods: one entry per unverified contact the registration code was just sent to, each with a type (EMAIL_OTP / SMS_OTP), a contactMethodId, and a maskedTarget like a***@example.com. Use it to tell the new user where to look for their code. See Complete registration in one step below.

The challenge → verify loop

For email or SMS OTP, and for WebAuthn, call sendMfaChallenge first to deliver a code or fetch a sign-in challenge. Authenticator-app codes and backup codes need no challenge step: go straight to verifyMfa.

For email and SMS OTP, sendMfaChallenge accepts an optional targetContactMethodId (a contactMethodId from availableMfaMethods) naming the contact that should receive the code. Omit it and Forte picks for you: the exact contact the user signed in with when the first factor was a password with an email/phone identifier, otherwise the first eligible contact of that channel.

Testing MFA in sandbox

In sandbox projects, a contact method with a fixed test code uses that code for email/SMS MFA challenges too: sendMfaChallenge delivers nothing, and verifyMfa accepts the fixed code. This lets automated tests complete the full challenge → verify loop deterministically.

typescript
import { ForteClient } from "@forteplatforms/sdk";
 
const forte = new ForteClient();
 
// 1. First factor: your existing login call. The cookie is now a pending token.
const login = await forte.users.passwordLogin({
  projectId,
  passwordLoginRequest: { contactValue: "alice@example.com", password },
});
 
if (login.mfaStatus === "SATISFIED" || !login.mfaStatus) {
  // Fully signed in: no second factor required.
  return;
}
 
if (login.mfaStatus === "CHALLENGE_REQUIRED") {
  // login.availableMfaMethods lists what the user can use: one entry per eligible
  // contact. Suppose they pick an email OTP entry:
  const emailOtp = login.availableMfaMethods!.find((m) => m.type === "EMAIL_OTP")!;
  await forte.users.sendMfaChallenge({
    projectId,
    mfaChallengeRequest: { type: "EMAIL_OTP", targetContactMethodId: emailOtp.contactMethodId },
  });
 
  // The user reads the emailed code and enters it:
  const result = await forte.users.verifyMfa({
    projectId,
    mfaVerifyRequest: { type: "EMAIL_OTP", code: "123456" },
  });
  // result.mfaStatus === "SATISFIED": the cookie is now a full session token.
}

Verifying with an authenticator-app code or a backup code skips the challenge: call verifyMfa with type: "TOTP" (or "BACKUP_CODE") and the code directly. A wrong code returns 400 MFA_INVALID_CODE; an expired email/SMS code returns 400 MFA_CODE_EXPIRED; verifying an email/SMS code without an outstanding challenge returns 400 MFA_NO_ACTIVE_CHALLENGE: call sendMfaChallenge first. Requesting a new email/SMS code invalidates the previous one for that factor type: only the most recently sent code is accepted.

Non-cookie clients: pass the pending token explicitly

The preceding examples rely on the Forte-User-Session-Token cookie, which browsers send automatically. Mobile apps and server-side BFFs don't have that cookie, so read the pending token from the login response (login.sessionToken.sessionToken) and pass it as the authorization argument on every MFA call: sendMfaChallenge, verifyMfa, and the enrollment calls (for example, authorization: "Bearer " + pendingToken). This mirrors how other client-side calls authenticate in non-browser clients.

Complete registration in one step

When a project requires MFA and enables email or SMS one-time codes, a user who registers with a password lands in ENROLLMENT_REQUIRED — they have a password (their first factor) but no verified contact yet. The verification code Forte emails or texts at registration is their second factor: submit it once with verifyMfa and Forte both verifies the contact and completes sign-in, returning a full session. No sendMfaChallenge step, and no contactMethodId on the call — Forte resolves the contact from the pending token.

typescript
// 1. Register. The cookie is now a pending (ENROLLMENT_REQUIRED) token.
const signup = await forte.users.registerUser({
  projectId,
  registerUserRequest: { email: "alice@example.com", password, fullName: "Alice" },
});
 
if (signup.mfaStatus === "ENROLLMENT_REQUIRED") {
  // signup.pendingContactMethods tells you where the code went, e.g. "a***@example.com".
  // The user reads the emailed code and enters it — one call finishes signup:
  const result = await forte.users.verifyMfa({
    projectId,
    mfaVerifyRequest: { type: "EMAIL_OTP", code: "123456" },
  });
  // result.mfaStatus === "SATISFIED": the cookie is now a full session token,
  // and result.userObject shows the email as verified.
}

A wrong or expired code returns 400 INVALID_VERIFICATION_CODE / 400 VERIFICATION_CODE_EXPIRED (the same errors as the standalone contact-verification flow), and the pending token stays valid so the user can retry.

This one-step completion applies only when the project enables the matching OTP channel as a factor. On a project that requires MFA but allows only an authenticator app or passkey, and for passwordless (OTP-only) registrations, the new user instead enrolls a second factor before their session is granted.

Enroll a second factor

Enrolling is a two-step ceremony: create the method (still unverified), then activate it by proving it works. Enrollment endpoints accept a full session token, or a pending ENROLLMENT_REQUIRED token (so a user the project forces into MFA can enroll before they have a full session).

Authenticator app (TOTP)

Create returns a secret and an otpauthUri. Render the URI as a QR code (or show the secret for manual entry); the user scans it with their authenticator app and enters the current 6-digit code to activate.

typescript
// 1. Create: returns the secret + otpauth URI.
const created = await forte.users.createMfaMethod({
  projectId,
  createMfaMethodRequest: { type: "TOTP", displayName: "My phone" },
});
// Render created.otpauthUri as a QR code (e.g. with the `qrcode` package).
// created.secret is the same secret, for manual entry.
 
// 2. Activate: the user enters the current code from their app.
await forte.users.activateMfaMethod({
  projectId,
  mfaMethodId: created.mfaMethodId,
  activateMfaMethodRequest: { code: "123456" },
});

The otpauthUri issuer is your project's name, so it appears under your brand in the user's authenticator app. The secret is shown only at creation and is never returned again.

Passkeys and security keys (WebAuthn)

WebAuthn enrollment and sign-in both run a browser ceremony through navigator.credentials. Forte is the relying party and does all the cryptography; your frontend only passes JSON between Forte and the browser. The create/challenge calls return a JSON options string designed for the browser's native PublicKeyCredential.parseCreationOptionsFromJSON() / parseRequestOptionsFromJSON(), and the resulting credential's .toJSON() is what you send back.

typescript
// Helper: run a WebAuthn registration ceremony from Forte's JSON options.
async function registerPasskey(optionsJson: string): Promise<string> {
  const options = PublicKeyCredential.parseCreationOptionsFromJSON(JSON.parse(optionsJson));
  const credential = await navigator.credentials.create({ publicKey: options });
  return JSON.stringify((credential as PublicKeyCredential).toJSON());
}
 
// Helper: run a WebAuthn assertion (sign-in) ceremony.
async function assertPasskey(optionsJson: string): Promise<string> {
  const options = PublicKeyCredential.parseRequestOptionsFromJSON(JSON.parse(optionsJson));
  const credential = await navigator.credentials.get({ publicKey: options });
  return JSON.stringify((credential as PublicKeyCredential).toJSON());
}

Enroll a passkey: create returns webAuthnCreationOptions; activate submits the attestation:

typescript
const created = await forte.users.createMfaMethod({
  projectId,
  createMfaMethodRequest: { type: "WEBAUTHN", displayName: "MacBook Touch ID" },
});
 
const attestation = await registerPasskey(created.webAuthnCreationOptions!);
 
await forte.users.activateMfaMethod({
  projectId,
  mfaMethodId: created.mfaMethodId,
  activateMfaMethodRequest: { webAuthnAttestation: attestation },
});

Sign in with a passkey: challenge returns webAuthnRequestOptions; verify submits the assertion:

typescript
const challenge = await forte.users.sendMfaChallenge({
  projectId,
  mfaChallengeRequest: { type: "WEBAUTHN" },
});
 
const assertion = await assertPasskey(challenge.webAuthnRequestOptions!);
 
await forte.users.verifyMfa({
  projectId,
  mfaVerifyRequest: { type: "WEBAUTHN", webAuthnAssertion: assertion },
});
A passkey works only on the domain it was registered on

WebAuthn binds every credential to the exact host (app.example.com) it registered on: this is part of the standard, and it's what makes passkeys phishing-resistant. A passkey registered on one domain cannot be used on another, and the browser itself refuses to allow it. If your users reach your app on more than one domain, they enroll (and Forte offers) a separate passkey per domain. No configuration is needed. Forte uses the request host as the relying-party ID automatically and only surfaces a user's passkeys on the host they belong to. WebAuthn requires HTTPS (or localhost for development).

PublicKeyCredential.parseCreationOptionsFromJSON, parseRequestOptionsFromJSON, and toJSON are available in current Chrome, Edge, Safari, and Firefox. For older browsers, base64url-decode the challenge, user.id, and credential id fields into ArrayBuffers yourself before calling navigator.credentials, and base64url-encode the response fields on the way back.

Backup codes

Generate a set of one-time recovery codes for the user to store somewhere safe. The plaintext codes are returned once, at generation; regenerating invalidates any prior set.

typescript
const { codes, remainingCount } = await forte.users.generateBackupCodes({ projectId });
// Show `codes` to the user once and tell them to save them. Forte stores only hashes.
 
// Later, check how many are left (without revealing them):
const status = await forte.users.getBackupCodeStatus({ projectId });
// status.remainingCount, status.generatedAt

To sign in with a backup code, call verifyMfa with type: "BACKUP_CODE" and the code. Each code works once; a used or unknown code returns 400 MFA_BACKUP_CODE_INVALID.

Backup codes are recovery supplements, not a first enrolled factor. The user must activate a TOTP or WebAuthn method before generating or replacing backup codes; otherwise Forte returns 400 MFA_PRIMARY_METHOD_REQUIRED.

Manage enrolled methods

A fully signed-in user can list, rename, and remove their enrolled devices. listMfaMethods returns a masked view (no secrets) and works with a pending token too, so you can show a user their methods mid-sign-in.

typescript
const { methods } = await forte.users.listMfaMethods({ projectId });
// Each method: mfaMethodId, type, displayName, verified, createdAt, lastUsedAt
 
await forte.users.renameMfaMethod({
  projectId,
  mfaMethodId,
  renameMfaMethodRequest: { displayName: "Work laptop" },
});
 
await forte.users.deleteMfaMethod({ projectId, mfaMethodId });

When the project requires MFA, Forte refuses to delete a user's last usable factor: deleteMfaMethod returns 400 MFA_LAST_METHOD_REQUIRED: so a user can't lock themselves out. Have them enroll a replacement first.

Recovery

If a user loses their authenticator or passkey, their options, in order of preference:

  1. Another enrolled factor. One-time codes by email or SMS (if your project enables them) and backup codes are independent of the lost device.
  2. A backup code, via verifyMfa with type: "BACKUP_CODE".
  3. Administrative reset: clearing all a user's MFA methods so they can re-enroll. This is intentionally limited to sandbox projects, where you're building and testing. In production, a user with no factors and no backup codes recovers through your own support process; Forte does not expose an unauthenticated MFA bypass.
typescript
// Admin reset: sandbox projects only (uses your FORTE_API_TOKEN, not the user session).
await forte.users.adminResetUserMfa({ projectId, userId });
// Returns MFA_ADMIN_ACTION_SANDBOX_ONLY (400) on a non-sandbox project.

Error reference

CodeHTTPWhen
MFA_REQUIRED401A pending token was used on a request that needs a full session. Complete MFA first.
MFA_NOT_ENABLED400Verified against a session that has no MFA challenge pending.
MFA_METHOD_TYPE_NOT_ENABLED400The factor isn't enabled on the project, or the contact channel isn't verified.
MFA_METHOD_NOT_FOUND404No enrolled method with that ID.
MFA_METHOD_ALREADY_EXISTS409An authenticator app is already enrolled.
MFA_INVALID_CODE400Wrong TOTP/OTP code, or a failed WebAuthn ceremony.
MFA_CODE_EXPIRED400The email/SMS code's window has passed (or the code was burned by too many wrong attempts).
MFA_NO_ACTIVE_CHALLENGE400verifyMfa was called for an email/SMS/WebAuthn factor but no challenge is outstanding: call sendMfaChallenge first.
MFA_CHALLENGE_RATE_LIMITED429A challenge was requested again too soon (resends are throttled to once per 60 seconds).
MFA_BACKUP_CODE_INVALID400The backup code is unknown or already used.
MFA_PRIMARY_METHOD_REQUIRED400Activate an authenticator app or WebAuthn method before generating backup codes.
MFA_CONFIG_INVALID400The project MFA settings have no reliable first-factor/second-factor completion path.
MFA_LAST_METHOD_REQUIRED400Deleting this method would leave a required-MFA user with no factor.
MFA_FIRST_FACTOR_CONTACT_NOT_ALLOWED400The OTP target is the contact the first factor already used (for example, email OTP to the same address the user signed in with, or the address that received their password-reset link while other factors exist).
MFA_FIRST_FACTOR_CONTACT_REQUIRED400The user signed in with a password + contact identifier, and a same-channel OTP was requested for a different contact: the code must go to the sign-in contact.
MFA_TARGET_CONTACT_INVALID400targetContactMethodId doesn't name one of the user's verified contacts on the requested channel, or was supplied for a non-contact factor.
OTP_LOGIN_DISABLED_UNDER_MFA400This project turns off passwordless OTP sign-in (the Disable passwordless one-time-code sign-in setting).
MFA_ADMIN_ACTION_SANDBOX_ONLY400Administrative MFA reset was attempted on a non-sandbox project.

Next steps

Search

Search your resources, console pages, and documentation