BetterIAM

Multi-factor authentication

When a second factor is required, how people complete it with TOTP, recovery codes, emailed codes, or passkeys, and how remembered devices and step-up work.

Passwords get phished, reused across sites, and guessed. Multi-factor authentication (MFA) asks for a second, independent proof, so a stolen password or a compromised mailbox alone is not enough to take over an account. Better IAM supports authenticator apps (TOTP) with recovery codes, passkeys, and, where an organization allows it, one-time codes sent to a verified email address. A browser that completed MFA can be remembered for a while so people are not asked on every sign-in.

MFA is built in: there is no plugin to install and no migration of its own. You decide who must use it, and your login page handles the challenge.

Setup

Decide who must use MFA

Root administrators and anyone with an authenticator enrolled always need MFA. Add your own rule with authentication.requireMfa, and let each organization add theirs with its tenant policy:

iam.ts
import { betterIam } from 'better-iam';
import { sqliteAdapter } from 'better-iam/adapter-sqlite';

export const iam = betterIam({
  database: sqliteAdapter({ filename: './iam.db' }),
  secret: process.env.BETTER_IAM_SECRET,
  baseURL: process.env.BETTER_IAM_BASE_URL,
  authentication: {
    appName: 'Acme Cloud', // the label authenticator apps show
    // Adds to (never replaces) each tenant's own requirement.
    requireMfa: (tenant, identity) => tenant.type === 'organization' && identity.owner,
    trustedDeviceLifetimeMs: 14 * 24 * 60 * 60_000, // "remember this device" for 14 days; 0 turns it off
    mfaEmailCodes: false, // true lets people without an authenticator use emailed codes
    sendEmail: async (message) => {
      /* deliver with your mail provider; needed for emailed codes */
    },
  },
});

Migrate

MFA records live in the document store that migrate already created (see Schema):

npx better-iam migrate --config better-iam.config.mjs

Handle the challenge in your login page

Every first factor returns either a session or an mfaRequired challenge. Create one client and branch on the result; Completing a challenge shows each branch.

lib/iam-client.ts
import { createIamClient } from 'better-iam/client';
import type { iam } from './iam';

export const client = createIamClient<typeof iam>();

When MFA is required

You rarely switch MFA on per person; rules make it required. MFA is required when any of these holds:

  • the person is a root administrator (always, and never through a remembered device or an emailed code);
  • the person has an authenticator enrolled;
  • the policy sets requireMfa, or requireMfaForOwners and the person is an owner;
  • the deployment's authentication.requireMfa(tenant, identity) callback returns true.

Use the tenant policy for rules an organization decides for itself. Use the callback for rules that depend on your own logic, such as a plan tier or a tenant type.

The requirement applies to password, passwordless, and federated sign-in alike, and to sessions that already exist: they are re-validated on every use, so requiring MFA in a tenant locks out sessions that did not complete it (MFA_REQUIRED) on their next request. A person cannot disable MFA while their tenant or the root role requires it. Resetting a password does not remove MFA.

Usage

Completing a challenge

After a correct password (or magic link, or federated sign-in), a person who needs MFA is not signed in yet. The first factor returns a challenge, a short-lived ticket that proves the first step succeeded, instead of a session. Your UI then asks for the second factor and sends it with the challenge:

MfaRequired
type MfaRequired = {
  mfaRequired: true;
  challenge: string;
  enrollmentRequired: boolean; // no second factor is set up yet
  authenticatorEnrolled?: boolean; // an authenticator app or recovery code can answer
  emailCodeAvailable?: boolean; // requestMfaCode may email a code
  passkeyAvailable?: boolean; // a registered passkey may answer
};

The challenge is single-use and valid for five minutes. What the person does next depends on the flags:

authenticatorEnrolled: true: the person has an authenticator app. They enter its current code, and auth.verifyMfa checks it against the challenge and issues the session.

import { client } from './lib/iam-client';

const session = await client.auth.verifyMfa({
  tenantId,
  challenge: result.challenge,
  code: '123456',
  rememberDevice: true, // optional; see below
});

Route: POST /api/iam/auth/verifyMfa

On the server, the same calls take the same inputs. They are public (the challenge is the credential), and a direct call returns the session token instead of setting a cookie:

Server
import { iam } from './iam';

const result = await iam.api.auth.signIn({ tenantId, email, password });
if ('mfaRequired' in result) {
  const { token, session, deviceToken } = await iam.api.auth.verifyMfa({
    tenantId,
    challenge: result.challenge,
    code,
    rememberDevice: true,
  });
}

A wrong authenticator code, emailed code, or recovery code for a real account fails with INVALID_MFA and is recorded as a failed attempt (auth:signin:fail with reason: 'mfa' or 'recovery-code'). Second-factor attempts are rate limited on the sensitive tier both per challenge and per person, so a correct password does not buy a fresh budget of code guesses.

Authenticator apps (TOTP)

An authenticator app on a phone or in a password manager generates a six-digit code that changes every 30 seconds (TOTP, time-based one-time passwords). It works offline, costs nothing to send, and is the factor most people already know.

  • auth.beginMfa generates a secret and an otpauth:// URI labeled with the person's email and your appName. The secret is stored with authenticated encryption.
  • Enrollment must be confirmed within ten minutes, with the same credential that started it: the sign-in challenge, or a recently authenticated session.
  • Codes are six digits on 30-second steps, and a code from the adjacent step is accepted to absorb clock drift. Each time step is accepted once, so a captured code cannot be replayed (INVALID_MFA).
  • From a sign-in challenge, a person who already has an authenticator or a passkey cannot start an enrollment (MFA_REQUIRED): they must use their existing factor first. From a signed-in session, a person who already has an authenticator gets MFA_ALREADY_ENABLED from beginMfa.

People who are not required to use MFA can still turn it on from their account page. They enroll from a signed-in session, which needs recent authentication:

import { client } from './lib/iam-client';

const { uri } = await client.auth.beginMfa(); // no challenge: the session is the credential
const { recoveryCodes } = await client.auth.confirmMfa({ code: '123456' });

Routes: POST /api/iam/auth/beginMfa, POST /api/iam/auth/confirmMfa

Confirming an enrollment ends every existing session of the person and returns a new, MFA-verified one. Through the HTTP handler the new session replaces the cookie. Enrollment is audited as auth:mfa:enable.

Recovery codes

Phones get lost and reset. Recovery codes are the way back in without an administrator: enrollment returns ten single-use codes, which the person should store somewhere safe, such as a password manager. They are stored hashed and consumed on use.

  • auth.recoverMfa({ tenantId, challenge, code }) completes a sign-in challenge with one (audited as auth:mfa:recover).
  • auth.regenerateRecoveryCodes() replaces the whole set. It needs recent authentication and an MFA-verified session (audited as auth:mfa:recovery-codes), and fails with MFA_NOT_ENROLLED when no authenticator is enrolled.
  • auth.mfaStatus() reports recoveryCodesRemaining, so your account page can prompt people to regenerate before they run out.
import { client } from './lib/iam-client';

const { recoveryCodesRemaining } = await client.auth.mfaStatus();
if (recoveryCodesRemaining < 3) {
  const { recoveryCodes } = await client.auth.regenerateRecoveryCodes();
  showRecoveryCodesOnce(recoveryCodes);
}

Routes: POST /api/iam/auth/recoverMfa, POST /api/iam/auth/regenerateRecoveryCodes, POST /api/iam/auth/mfaStatus

Emailed one-time codes

Emailed codes let an organization require MFA without forcing every member to install an authenticator. They are off by default; turn them on per tenant with mfaEmailCodes or for the deployment with authentication.mfaEmailCodes (the tenant setting wins).

  • A sign-in offers them (emailCodeAvailable: true) only to people with nothing enrolled and a verified email address, only when sendEmail is configured, and never to root administrators.
  • auth.requestMfaCode emails a six-digit code (template mfa-code) bound to that challenge. It is hashed at rest, single-use, and valid for at most ten minutes and never beyond the challenge itself. Requesting again replaces the code, and requests are rate limited per challenge and per person so repeated sign-ins cannot flood a mailbox.
  • A challenge that did not offer codes fails with FEATURE_DISABLED.

Route: POST /api/iam/auth/requestMfaCode

Weaker than an authenticator

A code rides on the mailbox, so it is weaker than an authenticator. People with an authenticator enrolled, and root administrators always, must use the authenticator or a recovery code.

Passkeys as a second factor

Whenever a person has a passkey registered, the challenge carries passkeyAvailable: true and they can complete MFA with it instead of typing a code. The assertion is verified like a passkey sign-in and bound to the pending sign-in challenge, which must still be open and belong to the same person; both challenges are consumed together. A passkey sign-in on its own already satisfies MFA. See passkeys.

Remember this device

Asking for a code every time someone signs in from the same laptop is tedious, and tedium pushes organizations to turn MFA off. "Remember this device" lets a browser that recently completed MFA skip the second factor for a while, while a new device, or a stolen password used elsewhere, still has to pass it.

rememberDevice: true on verifyMfa, confirmMfa, or finishPasskeyMfa asks Better IAM to remember the browser. When the deployment and the tenant allow it, the response carries a deviceToken and deviceExpiresAt. Passing that token to signIn or finishPasswordless later satisfies the MFA requirement without a code.

  • Lifetime. The shorter of authentication.trustedDeviceLifetimeMs (30 days by default, one year at most, 0 disables the feature) and the tenant's trustedDeviceDays (0 turns it off for the tenant).
  • Storage. The token is opaque and stored hashed. Through the HTTP handler it lives in its own HttpOnly better-iam.device cookie (__Host- prefixed over HTTPS), which the handler injects into later signIn and finishPasswordless bodies, so the client only has to send rememberDevice: true once.
  • Root administrators are never remembered.
  • Invalid or expired tokens simply lead to the normal challenge.
  • Sessions established this way carry trustedDeviceId. They count as MFA-verified (principal.mfa) but carry no fresh factor time (principal.mfaTime is absent).

On the server, pass the stored token yourself:

Server
import { iam } from './iam';

const result = await iam.api.auth.signIn({ tenantId, email, password, deviceToken });

People manage their remembered devices from their account page:

  • auth.listTrustedDevices lists the live ones, newest use first, with the client details, createdAt, lastUsedAt, and expiresAt, so people can recognize each browser.
  • auth.revokeTrustedDevice({ deviceId }) forgets one device, for example a shared computer. The next sign-in from it asks for MFA again. Needs recent authentication.
  • auth.revokeTrustedDevices() forgets all of them, and through the HTTP handler also clears the device cookie. Needs recent authentication.
import { client } from './lib/iam-client';

const devices = await client.auth.listTrustedDevices();
await client.auth.revokeTrustedDevice({ deviceId: devices[0].id });

Routes: POST /api/iam/auth/listTrustedDevices, POST /api/iam/auth/revokeTrustedDevice, POST /api/iam/auth/revokeTrustedDevices

Every remembered device is forgotten when the person changes or resets their password, changes their email, enrolls or disables an authenticator, or removes a passkey, when an administrator revokes their sessions or everyone's in the tenant, and on revokeTrustedDevices. Remembering and forgetting are audited as auth:device:trust and auth:device:revoke.

Managing factors

An account page usually shows what a person has set up and lets them change it. These calls work from a signed-in session:

CallRouteNeedsEffect
auth.mfaStatus()POST /api/iam/auth/mfaStatusa sessionSummarizes the person's setup: enabled, recoveryCodesRemaining, passkeys, trustedDevices, and sessionMfa (whether this session passed MFA).
auth.beginMfa(), auth.confirmMfa({ code })POST /api/iam/auth/beginMfa, POST /api/iam/auth/confirmMfarecent authenticationEnrolls an authenticator (audited as auth:mfa:enable).
auth.regenerateRecoveryCodes()POST /api/iam/auth/regenerateRecoveryCodesrecent authentication, MFA sessionReplaces the recovery codes, when they run low or may have been seen.
auth.disableMfa()POST /api/iam/auth/disableMfarecent authentication, MFA sessionRemoves the authenticator and ends every session (audited as auth:mfa:disable). Refused with MFA_REQUIRED for root administrators and when the tenant requires MFA.
import { client } from './lib/iam-client';

const status = await client.auth.mfaStatus();
if (status.enabled && confirm('Turn off two-step verification?')) await client.auth.disableMfa();

Step-up

Some actions deserve more than a valid session: refunding a payment, deleting a project, changing who is an owner. asks the person to prove themselves again, or with MFA, right before such an action. Better IAM gives you three building blocks:

  • Recent authentication. Sensitive operations require a session established within recentAuthenticationMs (five minutes by default) and fail with RECENT_AUTH_REQUIRED otherwise. auth.reauthenticate({ password }) re-runs the password ceremony, and the second factor when the account has one, and issues a fresh session. See recent authentication.

  • Policy conditions. can read facts about the (the caller): principal.mfa (the session passed MFA), principal.mfaTime (when a first-hand factor was last verified; absent for remembered-device, impersonation, and API-key sessions), and principal.authMethod. A policy can demand MFA for one action only:

    {
      effect: 'allow',
      actions: ['billing:refund'],
      resources: ['invoice/*'],
      conditions: { Bool: { 'principal.mfa': true } },
    }
  • Framework guards. The framework integrations accept a stepUp requirement (mfa: true, mfa: 'fresh', or maxAgeMs) on pages, routes, and actions, and redirect to your stepUpPath with a reason. See Next.js guards.

Just-in-time elevation can also require an MFA-verified session for every of an administrator role; see elevation.

Options

Deployment options live under authentication; tenant fields are set with tenants.setAuthPolicy.

OptionTypeDefaultDescription
authentication.requireMfa(tenant, identity) => boolean | Promise<boolean>noneRequires MFA for whoever it returns true for, in addition to root administrators, enrolled people, and tenant policies.
authentication.mfaEmailCodesbooleanfalseLet people without an authenticator answer MFA with an emailed code. Tenants override it.
authentication.trustedDeviceLifetimeMsnumber30 daysHow long "remember this device" may skip MFA, up to one year. 0 disables it.
authentication.appNamestring'Better IAM'The issuer label in the otpauth:// URI.
authentication.recentAuthenticationMsnumber5 minutesHow recent a session must be to enroll, disable, regenerate codes, or forget devices.
authentication.rateLimits.sensitiveAttemptsnumber5Attempts per window for MFA codes, recovery codes, and code requests.
authentication.sendEmail(message) => Promise<void>noneRequired for emailed codes (mfa-code template).
Tenant requireMfabooleanfalseEvery person in the tenant must complete MFA.
Tenant requireMfaForOwnersbooleanfalseOnly owners must complete MFA.
Tenant mfaEmailCodesbooleandeployment valueOverrides authentication.mfaEmailCodes for the tenant.
Tenant trustedDeviceDaysnumberdeployment value0 to 365; 0 turns remembered devices off for the tenant.

Schema

CollectionKey fieldsNotes
authMfaid (the identity ID), tenantId, identityId, encryptedSecret, recoveryHashes, enabled, lastUsedStepOne record per person with an authenticator. The secret is sealed with the deployment secret; recovery codes are hashed. lastUsedStep blocks code replay.
authDevicesid, tenantId, identityId, tokenHash, createdAt, expiresAt, lastUsedAt, clientRemembered devices. The device token is stored hashed.
authChallengesid, tenantId, identityId, purpose, tokenHash, expiresAt, payloadSign-in challenges, pending enrollments, and emailed codes, single-use and hashed.
sessionsmfa, mfaAuthenticatedAt, trustedDeviceIdThe MFA state of each session; see Sessions.

Errors

CodeWhen
MFA_REQUIREDA session without MFA is used where MFA is now required; enrolling from a challenge while an authenticator or passkey exists; regenerating codes or disabling MFA from a session that did not pass MFA; disabling MFA when it is required.
INVALID_MFAA wrong, expired, or already used authenticator code, emailed code, or recovery code.
INVALID_CHALLENGEThe sign-in challenge or the pending enrollment is invalid or expired.
MFA_ALREADY_ENABLEDbeginMfa for a person who already has an authenticator.
MFA_NOT_ENROLLEDRegenerating recovery codes without an enrolled authenticator, or verifyMfa for a person with no authenticator and no emailed code requested for the challenge.
FEATURE_DISABLEDRequesting an emailed code for a challenge that did not offer one, or for a person without a verified email.
RECENT_AUTH_REQUIREDChanging factors or devices from a session that is not recent.
RATE_LIMITEDToo many second-factor attempts or code requests.
NOT_FOUNDForgetting a remembered device that is not the caller's.

Next steps

Was this page helpful?

Better IAM is created by Sean Filimon

Last updated

On this page