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.
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, orrequireMfaForOwnersand 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:
authentication: {
// Adds to (never replaces) each tenant's own requirement.
requireMfa: (tenant, identity) => tenant.type === 'organization' && identity.owner,
},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.
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:
type MfaRequired = {
mfaRequired: true;
challenge: string;
enrollmentRequired: boolean; // no authenticator enrolled yet
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:
enrollmentRequired: false: the person has an authenticator app. They enter its current code, and
auth.verifyMfa checks it against the challenge and issues the session.
const session = await client.auth.verifyMfa({
tenantId,
challenge: result.challenge,
code: '123456',
rememberDevice: true, // optional; see below
});A wrong authenticator code, emailed code, or recovery code for a real account 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.beginMfagenerates a secret and anotpauth://URI labeled with the person's email and yourappName. 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.
- A person who already has an authenticator gets
MFA_ALREADY_ENABLEDfrombeginMfa, and cannot start a new enrollment from a sign-in challenge (MFA_REQUIRED): they must use their existing factor first.
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:
const { uri } = await client.auth.beginMfa(); // no challenge: the session is the credential
const { recoveryCodes } = await client.auth.confirmMfa({ code: '123456' });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.
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 asauth:mfa:recover).auth.regenerateRecoveryCodes()replaces the whole set. It needs recent authentication and an MFA-verified session (audited asauth:mfa:recovery-codes).auth.mfaStatus()reportsrecoveryCodesRemaining, so your account page can prompt people to regenerate before they run out.
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 whensendEmailis configured, and never to root administrators. auth.requestMfaCodeemails a six-digit code (templatemfa-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.
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,0disables the feature) and the tenant'strustedDeviceDays(0turns 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.devicecookie (__Host-prefixed over HTTPS), which the handler injects into latersignInandfinishPasswordlessbodies, so the client only has to sendrememberDevice: trueonce. - 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.mfaTimeis absent).
People manage their remembered devices from their account page:
auth.listTrustedDeviceslists the live ones, newest use first, with the client details,createdAt,lastUsedAt, andexpiresAt, 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.
const devices = await client.auth.listTrustedDevices();
await client.auth.revokeTrustedDevice({ deviceId: devices[0].id });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:
| Call | Needs | Effect |
|---|---|---|
auth.mfaStatus() | a session | Summarizes the person's setup for an account page: enabled, recoveryCodesRemaining, passkeys, trustedDevices, and sessionMfa (whether this session passed MFA). |
auth.beginMfa(), auth.confirmMfa({ code }) | recent authentication | Enrolls an authenticator (audited as auth:mfa:enable). |
auth.regenerateRecoveryCodes() | recent authentication, MFA session | Replaces the recovery codes, when they run low or may have been seen. |
auth.disableMfa() | recent authentication, MFA session | Removes the authenticator and ends every session (audited as auth:mfa:disable). Refused with MFA_REQUIRED for root administrators and when the tenant requires MFA. |
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 withRECENT_AUTH_REQUIREDotherwise.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), andprincipal.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
stepUprequirement (mfa: true,mfa: 'fresh', ormaxAgeMs) on pages, routes, and actions, and redirect to yourstepUpPathwith 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.
Better IAM is created by Sean Filimon
Last updated
Sign-in methods
Passwords and password screening, self-registration, magic links, email and SMS codes, federated sign-in, API keys, and assumed roles.
Passkeys
Register passkeys, sign in with them (including discoverable autofill sign-in), use them as a second factor, and let people name and manage them.