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 also prove possession of a second factor before they get a full session. You enable and customize MFA per project; projects that leave it off behave exactly 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).
  • Email one-time passcode — 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 (forte.users.*). Call them from your frontend — responses set the Forte-User-Session-Token cookie automatically. Never call them from code that also holds FORTE_API_TOKEN.

Configuring 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. Email and SMS OTP ride the user's existing verified contact, so they need no enrollment.

Forte keeps the two factors on independent channels automatically. If a user signs in with an email (or SMS) one-time code, that channel is dropped from their second-factor options and Forte rejects an attempt to use it anyway — so a single inbox can never satisfy both steps. (When that would leave the user with no usable second factor, an OPTIONAL project signs them in normally and a REQUIRED project makes them enroll a real one.)

You can go further and stop passwordless OTP from being a sign-in factor at all 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. Email and SMS one-time codes 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 leaving both steps on the same channel.

How an MFA-aware sign-in works

Every first-factor flow you already use — googleAuthLoginCallback, registerUser, completeOtpLogin, passwordLogin, and password reset — returns the same LoginUserResponse it always did, now 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 satisfy 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 (/me/mfa/challenge, /me/mfa/verify, /me/mfa/methods, backup codes, and logout). It is rejected — with 401 MFA_REQUIRED — by every request to your deployed app and by every other management endpoint. 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 maskedTarget like a***@example.com. Present these as the user's choices.

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.

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. Suppose they pick email OTP:
  await forte.users.sendMfaChallenge({
    projectId,
    mfaChallengeRequest: { type: "EMAIL_OTP" },
  });
 
  // 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.

Non-cookie clients: pass the pending token explicitly

The examples above 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 (e.g. authorization: "Bearer " + pendingToken). This mirrors how other forte.users.* calls authenticate in non-browser clients.

Enrolling second factors

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 of 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 was created 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 will refuse to. 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.

Managing 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. Email/SMS OTP (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 of 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.
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_LAST_METHOD_REQUIRED400Deleting this method would leave a required-MFA user with no factor.
MFA_SAME_CHANNEL_NOT_ALLOWED400A second-factor OTP was requested or verified on the same channel the user signed in with (e.g. email OTP after an email-OTP login).
OTP_LOGIN_DISABLED_UNDER_MFA400Passwordless OTP sign-in is disabled for this project (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 documentation and console pages